Skip to content

Manifest v2

The manifest is the contract between your app and the platform. Both SDKs validate it at load with extra="forbid" / .strict() — unknown fields fail loudly so typos don’t silently disable features.

apiVersion: linkworld.ai/v2
app_id: my-bot
version: 0.1.0
name: My Bot
runtime:
image: ghcr.io/you/my-bot:0.1.0

That’s it. No tools, no scopes, no schedules — your app boots, exposes /health, and idles.

apiVersion: linkworld.ai/v2
app_id: tax-bot
version: 1.2.3
name: Tax Bot
description: Triages inbound emails into tax categories
icon: receipt
category: finance
runtime:
image: ghcr.io/you/tax-bot:1.2.3
port: 8080 # optional, default 8080
resources:
memory_mb: 512 # 64..4096
cpu_cores: 1.0 # 0.1..4
env:
DEBUG: "true"
network:
egress_hosts:
- api.openai.com
required_scopes:
- mail.send
- files.read
- odoo.read
tools:
- name: classify_invoice
description: Categorize text as invoice/receipt/payroll/other
scopes_required: []
agent_visible: true # optional, default true
lifecycle:
on_install: true
on_uninstall: true
on_inbound: true
on_user_added: false
schedules:
- name: daily-summary
cron: "0 8 * * *"
workflows: # orchestrator routing hints
- id: classify_inbound_invoice
name: Classify inbound invoice
intent: User forwards or pastes an invoice and asks how to book it
triggers:
- "Inbound email contains invoice attachment or invoice text"
- "User asks 'wie buche ich diese rechnung'"
flow:
- "classify_invoice(text=invoice_text) → category"
- "email_send(to=user.email, body='Buchungs-Empfehlung: ...')"
tools_required: [classify_invoice, email_send]

Required. Must be linkworld.ai/v2.

Required. URL-safe slug, immutable across versions. Lowercase alphanumeric + hyphens, 3–64 chars, must start with a letter.

Required. Semantic version (X.Y.Z or X.Y.Z-pre). Each value is a distinct, immutable artifact — bump it on every publish.

Display fields shown in the marketplace and the dev console. name is required; the rest are optional. category is a free-form string (common values: finance, productivity, support, general).

Required. The OCI image the platform pulls when provisioning a container. Must be reachable by the platform’s pull credentials — GHCR public images work out of the box.

Optional, default 8080. Must be 1024–65535.

Optional, default 256. Range 64–4096. Hard cap enforced by the platform.

Optional, default 0.5. Range 0.1–4.0.

Optional. Environment variables baked into the container image at run. Use this for non-secret config (region, debug flags, feature toggles). For secrets, use ctx.secrets.get(...).

Optional. Hostnames your app needs outbound access to. Documented on the app’s install page so the tenant sees which third parties this app reaches.

Required for any app calling platform tools. List every scope your app’s ctx.tools.call(...) paths might need. The tenant must grant these on install. See Scope catalog.

Optional. List of integration provider slugs (e.g. m365, odoo, business_central) the app needs OAuth/credentials for. Activation fails closed when any listed provider isn’t configured for the tenant — the activation UI surfaces a “Connect first” prompt and links to /user/integrations.

required_integrations:
- m365 # needed for mail.read / mail.send to actually work

Use this for apps whose scheduled work depends on user-level OAuth (e.g. inbox sweeps). Without it, install succeeds, the schedule fires, and tools silently return empty — the user blames your app, not the missing connection.

Optional. Ship Markdown documentation for end-users (not developers) alongside your app source. linkworld deploy auto-discovers app_docs/**/*.md and uploads them as part of the manifest blob; the platform renders them as:

  • docs.linkworld.ai/apps/<your-slug>/<topic>/ — Starlight pages with the same look-and-feel as this reference, searchable + deep-linkable
  • The in-app Help tab in the right-side chrome panel

File layout:

your-app/
├── linkworld.app.yaml
├── main.py
├── app_docs/
│ ├── user.md ← required entry point
│ ├── getting-started.md
│ └── workflows/
│ └── lead-import.md
└── web/

Caps: 5 MB total, 500 KB per file, 200 files. Enforced both client-side (at linkworld deploy) and server-side (at version register).

Apps without an app_docs/user.md don’t appear on docs.linkworld.ai at all — discovery happens via the marketplace listing, the docs site is reserved for apps with real narrative content. The auto-Reference section (tools, agents, scopes, install_settings) is appended at render time from this manifest, so you only write the prose.

External docs (docs_url): if you host docs elsewhere (Notion, GitBook, your own site), set the top-level docs_url: field instead. The platform shows a link card and skips the app_docs/ rendering:

docs_url: https://docs.example.com/my-app/

See Documenting your app for the full guide + the canonical Pipeline example.

Document-template defaults (./defaults/templates/)

Section titled “Document-template defaults (./defaults/templates/)”

Optional. Ship doc-template defaults with your app so business_render_pdf works on a fresh install without operator intervention.

File layout (one JSON per template, in your app’s repo root):

your-app/
├── linkworld.app.yaml
├── main.py
├── web/
└── defaults/
└── templates/
├── quote.json
├── invoice.json
└── letter.json

Template JSON shape (mirrors linkworld_doc_templates):

{
"slug": "quote",
"name": "Angebot",
"description": "Standard-Angebotsvorlage",
"sections": [ { "type": "markdown", "id": "intro", "content": "" } ],
"metadata": {}
}

slug must match ^[a-z][a-z0-9_]*$ — same regex as linkworld_doc_templates.slug. Drop app_ids if you’re migrating from a first-party-app’s defaults: the platform auto-tags the cloned rows with your app slug at activation, so a hardcoded list is wrong.

Publish flow:

  1. linkworld deploy scans ./defaults/templates/*.json.
  2. After register_version, CLI POSTs the array to /api/dev/apps/<slug>/versions/<v>/defaults/templates.
  3. Platform persists the JSONB array on linkworld_product_versions.default_templates — immutable per version (bump to ship changes, same rule as the frontend bundle).

Activation flow:

  1. Platform reads default_templates from the version row.
  2. For each entry, calls DocTemplateService.clone_payload_defaults(tenant_id, …, tag_app_id=<your-slug>). Idempotent on (tenant_id, slug).
  3. New rows land with is_active=true, is_default=true, app_ids=[<your-slug>].

Optional legacy bridge. Points at a first-party app slug whose packages/core/src/apps/<slug>/defaults/templates/*.json should be cloned at activation. Used by office-assistant-sdk during the migration window before it shipped its own ./defaults/templates/.

Activation skips this field when the version row already has default_templates (the new path wins). Third-party apps should not use this field — they don’t control core’s apps/ directory. Will be removed once the last consumer migrates.

Optional. Declares other tenant-apps your app calls via ctx.apps.fetch(...). Activation auto-creates + auto-approves a bilateral grant in linkworld_app_grants for each entry whose target is installed — no manual workspace-control click-through.

If a target isn’t installed yet, activation rejects with status='missing_app_dependencies' so the UI can prompt the user to install the missing app first.

cross_app_dependencies:
- app_id: office-assistant-sdk
reason: "Quote-Lifecycle (Draft → Sent → PDF-Render) für OFFER_REQUEST."
routes:
- "POST /documents"
- "POST /documents/{id}/transition"
- "POST /documents/{id}/render-pdf"
FieldRequiredNotes
app_idyesSlug of the target app
routesnoDocumentation-only list of <METHOD> <PATH> entries the activation UI shows the tenant (“This app will call …”)
reasonnoOne-sentence “why” displayed alongside the auto-grant disclosure

The auto-grant uses allowed_agents=['__route__'], the same shape manual workspace-control approvals produce. Tenants can revoke or narrow it via workspace-control like any other grant.

Optional. Custom tools your app exposes to the tenant agent.

FieldRequiredNotes
nameyesLowercase alphanumeric + underscores, 3–64 chars
descriptionyesOne-line; shown to the agent’s planner
scopes_requiredyesSubset of top-level required_scopes
agent_visibleno, default trueIf false, only your own handlers can call it

Optional. Declares which lifecycle hooks your app subscribes to. The SDK refuses to register a handler whose corresponding lifecycle.<hook> isn’t true — fast feedback when you forget to flip the flag.

FieldTypeNotes
on_installboolFires on first activation
on_uninstallboolFires on deactivation
on_inboundboolSubscribes to tenant inbound channel events (opt-in fan-out)
on_user_addedboolFires when a new user joins the tenant
schedules[]listNamed cron entries; minute precision
schedules:
- name: daily-summary
cron: "0 8 * * *"

name is the handle you pass to app.onSchedule(...). cron is standard 5-field cron (minute precision). The platform’s leader-elected scanner ticks every 60s.

Optional. Declarative intent → tool-flow mappings the tenant orchestrator reads to decide whether to invoke this app and what sequence to follow. See the workflow hooks guide for the full story; the table below is the schema reference.

FieldRequiredNotes
idyesLowercase slug, unique within a manifest, ≤64 chars
nameyesShort label shown to the orchestrator and audit log
intentyesOne sentence: when does this workflow apply?
flowyesOrdered list of steps (1–50, each ≤500 chars) — pseudo-code or natural-language
triggersnoUp to 20 phrases the orchestrator should match against the user’s request
non_triggersnoUp to 20 explicit negative cues — “NOT when X”
specificsnoFree-form notes for the executing agent (≤2000 chars): defaults, edge cases, output style
tools_requirednoTool names the flow needs. Workflows with unmet requirements get filtered out before reaching the orchestrator’s prompt.
channelsnoRestrict to specific channels (["whatsapp", "telegram"]) or ["*"] for all (default)

A manifest can ship up to 20 workflows. Tenants override individual workflow fields per-install via the platform’s settings UI; the override merge is keyed on id.

Optional. Declares this app’s specialist agent(s). Mutually exclusive — pick one form:

  • agent: (singular) — one specialist, simple case
  • agents: (plural) — up to 8 specialists, exactly one marked is_default: true. Pick at call time via ctx.agent.ask(prompt, agent="<id>"); omit to route to default.

When present, activation provisions one or more rows in linkworld_agents so the tenant orchestrator can delegate_to_agent to this app the same way it does for first-party apps (Office Assistant, Sachverständiger).

FieldRequiredNotes
idyes (plural form), ignored (singular)Lowercase letter-prefixed slug. The platform fills in default for the singular form.
nameyesDisplay name shown in the chat orchestrator’s catalog. ≤80 chars
system_promptyesThe specialist agent’s system prompt. Workflow flow + specifics are appended at request time. ≤20 000 chars
allowed_skillsnoOptional skill-name allow-list scoping which platform skills the agent’s tool calls reach. Null = no restriction (per-app scope gates still apply).
tools_allowednoPer-agent whitelist of THIS app’s declared tools. Null/missing = the agent can call all of them. Use to scope down e.g. a “replier” agent so it can’t post drafts.
is_defaultnoPlural form only. Exactly one entry must set this true. The platform marks it as the routing target for ctx.agent.ask calls without an explicit agent=.
routing_hintsnoFree-form text the orchestrator considers when picking between custom agents. Useful for tie-breakers between multiple installed apps with overlapping capabilities. ≤2000 chars

Singular form:

agent:
name: Tax Bot Specialist
system_prompt: |
You are the tax-bot specialist agent. You help users
classify invoices and book them in the right account.
allowed_skills: [graph_email, lexoffice]
routing_hints: |
Prefer this agent for tax-related questions and
invoice-classification flows.

Plural form:

agents:
- id: drafter
name: Post Drafter
is_default: true
tools_allowed: [draft_post, schedule_post]
system_prompt: |
You write LinkedIn posts in the user's voice.
Always plain text, ≤ 280 chars, no emojis.
- id: replier
name: Comment Replier
tools_allowed: [reply_comment]
system_prompt: |
You reply to comments. Tone matches the post's voice.
- id: classifier
name: Comment Classifier
system_prompt: |
You classify comments. Output JSON only.

Idempotent. Re-activation flips agents’ status to active without overwriting system_prompt, display_name, allowed_skills, tools_allowed, or routing_hints — tenants who edit an agent via the platform’s agents UI keep their changes across upgrades.

No agent block = headless app. Tools still callable directly (server-side from another agent’s flow, or from a frontend bundle’s bridge.tools.call), but the chat orchestrator gets no specialist branch to route to. Apps without agent are headless: tools + lifecycle + UI, no dedicated reasoning surface.

Optional. Adds an autonomous vision to the specialist agent — a multi-step goal the agent pursues continuously through the platform’s vision-loop scheduler (ASSESS → DEBATE → PLAN → EXECUTE → REVIEW). Activation creates a row in linkworld_agent_visions only when the tenant also picks a project via an install_settings field of type project_picker.

FieldRequiredNotes
textyesWhat the vision pursues. ≤4000 chars. The vision-loop reads this as the top-level goal statement.
success_criterianoList of plain-language criteria (≤20). Surfaces in the REVIEW phase as completion checks.
autonomy_modenosupervised (default) or full_autonomy. In supervised mode the loop pauses for user approval before executing major plans; in full_autonomy it runs through.
budget_max_goalsnoCap on goals the loop can spawn. Default 50, max 500.
budget_max_cost_centsnoOptional spend cap in cents (LLM + compute).
agent:
name: Tax Bot Specialist
system_prompt: |
You handle invoice triage and bookings.
vision:
text: |
Continuously triage tax-related emails as they land,
categorize them, and ship a weekly summary every Monday.
success_criteria:
- "Every inbound tax email categorized within 1h"
- "Weekly summary delivered Monday 09:00"
autonomy_mode: supervised
budget_max_goals: 100

Optional. Schema-driven inputs the platform collects from the tenant at install time. The activation consent screen renders the form from these declarations — partner devs do not ship a custom settings page. Stored on linkworld_tenant_apps.config keyed by setting id; readable from your handlers via ctx.config.

FieldRequiredNotes
idyesLowercase slug. Becomes the key in ctx.config.
typeyesOne of string, text, number, boolean, select, select_or_custom, project_picker, secret
labelyesShown to the tenant in the consent form
descriptionnoHelper text under the input
requirednoDefault false. Required fields block activation if missing.
defaultnoPre-filled value (must match the declared type)
optionsnoFor type: select / select_or_custom — list of allowed values
min / maxnoFor type: number — numeric range
expose_to_bundlenoDefault false. If true, the value is included in the iframe bundle’s init payload. Never expose secrets or PII — the iframe is untrusted UI.
auto_defaultnoPlatform fills the value server-side; the consent UI hides the field. Values: "activator_user_id" (user_id of the user clicking Activate) | "m365_primary_email" (userPrincipalName from Graph /me, requires required_integrations: [m365]). User-supplied values always win.
install_settings:
- id: target_inbox
type: string
label: Target inbox
description: Email address — must be one of your connected M365 mailboxes
required: true
- id: digest_time
type: string
label: Daily digest time
default: "08:00"
- id: language
type: select
label: Output language
options: [de, en, fr]
default: de
- id: brand_voice
type: select_or_custom
label: Schreibweise
description: Pick a preset or choose "Custom…" to write your own.
options:
- "Direct & friendly · short paragraphs · DE/EN"
- "Formal (Sie) · polite · complete sentences"
- "Casual (Du) · short · no signoff"
default: "Direct & friendly · short paragraphs · DE/EN"
- id: act_as_user_id
type: string
label: User for OAuth context
description: Auto-filled with the activator's user_id — UI hides this field.
auto_default: activator_user_id
required: true
- id: my_email_address
type: string
label: My primary email
description: Auto-resolved from Graph /me at activation; falls back to empty.
auto_default: m365_primary_email
- id: max_per_day
type: number
label: Max emails to process per day
default: 50
min: 1
max: 500
- id: project_id
type: project_picker
label: "Where should this app's autonomous goals run?"
description: "Pick a project to host this app's vision."
required: true

Reading values at runtime:

@app.on_inbound
async def handle(ctx, env):
inbox = ctx.config.get("target_inbox")
project_id = ctx.config.get("project_id")
...

Tenant edits to install settings happen post-install via the platform’s app-settings UI; values flow back through the same ctx.config field. No container restart required.

Optional. Declares custom HTTP endpoints your app exposes via @app.http_route — used for OAuth callbacks, third-party webhooks, signed downloads. Up to 30 entries.

FieldRequiredNotes
pathyesPath template, must start with /. Each segment is [a-z0-9_.-]+ literals or {name} placeholders, or a mix (e.g. /quotes/{quote_id}.pdf). Reserved first segments: /health, /manifest, /tool, /event, /route, /public.
methodyesOne of GET, POST, PUT, DELETE, PATCH (uppercase or lowercase — normalised to uppercase).
publicno, default falseWhen true, the route is reachable without a tenant session at /api/apps/<slug>/public/t/<tenant_slug>/<path>. Public routes must be GET.

Duplicate (method, path) pairs are rejected. Public routes with non-GET methods are rejected at deploy time.

http_routes:
- path: /oauth/callback
method: GET
public: true
- path: /quotes/{quote_id}.pdf
method: GET
public: false
- path: /webhook/stripe
method: POST
public: false # private webhook — needs tenant session, e.g. via signed URL the partner generated

The platform proxies matching requests to your container after enforcing a 15-mitigation security floor (header allowlists, body caps, content-type allowlist, forced CSP sandbox, status-code rewrite, …) — see SDK — Python reference. Public-route URL is built per-tenant via ctx.public_callback_url(path) in on_install.

Optional. Adds a platform-rendered right-side panel alongside your bundle iframe — Chat / Settings / Files / custom tabs. See Right-side panel guide for the full walkthrough.

chrome:
panel:
enabled: true
default_tab: chat # one of the tab ids below
width: 400 # px, 280–720
tabs:
- chat # built-in: scoped to your specialist agent
- settings # built-in: edit-mode for install_settings
- files # built-in: recent agent-generated files
- id: drafts # custom: sub-iframe at <bundle>/panel/drafts.html
label: Drafts
icon: file-text
path: /panel/drafts.html # optional; defaults to /panel/<id>.html
FieldRequiredNotes
panel.enabledno, default falseMust be true to render the panel
panel.default_tabnoid of the initially active tab
panel.widthno, default 400Panel width in pixels (280–720)
panel.tabs[]no, default ["chat"]Up to 8 entries; strings (built-in) or objects (custom)

Custom tab object fields:

FieldRequiredNotes
idyesLowercase slug, unique within the manifest
labelyesTab title (≤40 chars)
iconnolucide icon name (layout, message, folder, settings)
pathnoPath under bundle URL; defaults to /panel/<id>.html

Slugs this agent may delegate to via ctx.team.delegate. Validated: each entry must be another declared agent in the same manifest; no self-references; no duplicates. Singular agent: blocks may not declare team: (no peers to delegate to).

agents:
- id: cmo
name: CMO
system_prompt:
is_default: true
team: [researcher, content_drafter]

See Cross-app calls reference for runtime semantics.

Recurring agent routines with cheap pre-filters. Each entry is materialized at activation as a row in linkworld_app_data_feeds. Full schema in the Heartbeats reference.

agents:
- id: kpi_tracker
heartbeats:
- slug: hourly
skill: _internal_app_records
tool: query
params: { record_type: kpi }
interval_minutes: 60
active_hours: { start: "07:00", end: "23:00", timezone: user }
invoke_always: false
conversation_mode: dedicated
model_tier: cheap
prompt: Review KPI delta; escalate via team.delegate if off-pace.

Named slots your app binds to tenant-managed knowledge collections (RAGs) at activation time. The platform’s rag skill resolves each slot to a concrete RAG via linkworld_app_rag_bindings on every tool call — apps never see RAG IDs directly.

rag_slots:
- name: launch_corpus
description: Hooks, bio, competitive framing for the current launch.
required: true
scope: read # default; "read_write" for ingest apps
suggested_tags: [hook_a, hook_b, bio, proof]

Up to 10 slots per app. Slot names must be unique within the manifest and match ^[a-z][a-z0-9_]{0,31}$. Required slots without a binding block activation with status missing_rag_bindings.

Full surface (5 tools, slot resolution semantics, workspace-file ingestion, source-status decoration): see RAGs reference.

Pub/sub event declarations. emits lists event names this app may publish; subscribes_to declares incoming wires. Both are TYPE declarations — actual flow requires bilateral admin approval in Workspace Control → Wires & Grants.

# Emitter
emits: [lead_qualified, post_published]
# Subscriber
subscribes_to:
- emitter_app: marketing
event_name: lead_qualified
target_agent: bdr # invoke this agent on receipt
# OR target_heartbeat: <slug> to wake a heartbeat instead

Validators reject:

  • Duplicate event names in emits
  • subscribes_to.target_agent referencing an undeclared agent
  • target_heartbeat referencing an agent’s undeclared heartbeat
  • Bad event-name format (must be lowercase letter-prefixed, underscores allowed)

Subscriber side needs a code handler. Declaring the subscription in the manifest is necessary but not sufficient — the runtime dispatches the event to a @app.on_event(event_name) decorated handler in your code. Without that handler the wire fires but the app no-ops. See Cross-app calls reference → @app.on_event for the full pattern.

See Cross-app calls reference for ctx.events.emit runtime semantics.

  • Both SDKs use extra="forbid" semantics — a misspelled field rejects the manifest.
  • The platform re-validates on deploy; CI catches most drift before publish.
  • The shared fixture pool at packages/sdk-spec/fixtures/ is the single source of truth for what’s accepted.