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.
Minimal example
Section titled “Minimal example”apiVersion: linkworld.ai/v2app_id: my-botversion: 0.1.0name: My Botruntime: image: ghcr.io/you/my-bot:0.1.0That’s it. No tools, no scopes, no schedules — your app boots, exposes
/health, and idles.
Full example
Section titled “Full example”apiVersion: linkworld.ai/v2
app_id: tax-botversion: 1.2.3name: Tax Botdescription: Triages inbound emails into tax categoriesicon: receiptcategory: 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]Fields
Section titled “Fields”apiVersion
Section titled “apiVersion”Required. Must be linkworld.ai/v2.
app_id
Section titled “app_id”Required. URL-safe slug, immutable across versions. Lowercase alphanumeric + hyphens, 3–64 chars, must start with a letter.
version
Section titled “version”Required. Semantic version (X.Y.Z or X.Y.Z-pre). Each value is a
distinct, immutable artifact — bump it on every publish.
name, description, icon, category
Section titled “name, description, icon, category”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).
runtime.image
Section titled “runtime.image”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.
runtime.port
Section titled “runtime.port”Optional, default 8080. Must be 1024–65535.
runtime.resources.memory_mb
Section titled “runtime.resources.memory_mb”Optional, default 256. Range 64–4096. Hard cap enforced by the platform.
runtime.resources.cpu_cores
Section titled “runtime.resources.cpu_cores”Optional, default 0.5. Range 0.1–4.0.
runtime.env
Section titled “runtime.env”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(...).
network.egress_hosts
Section titled “network.egress_hosts”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_scopes
Section titled “required_scopes”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.
required_integrations
Section titled “required_integrations”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 /user/integrations.
required_integrations: - m365 # needed for mail.read / mail.send to actually workUse 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.
End-user docs (./app_docs/)
Section titled “End-user docs (./app_docs/)”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.jsonTemplate 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:
linkworld deployscans./defaults/templates/*.json.- After
register_version, CLI POSTs the array to/api/dev/apps/<slug>/versions/<v>/defaults/templates. - 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:
- Platform reads
default_templatesfrom the version row. - For each entry, calls
DocTemplateService.clone_payload_defaults(tenant_id, …, tag_app_id=<your-slug>). Idempotent on(tenant_id, slug). - New rows land with
is_active=true,is_default=true,app_ids=[<your-slug>].
seed_doc_templates_from (deprecated)
Section titled “seed_doc_templates_from (deprecated)”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.
cross_app_dependencies
Section titled “cross_app_dependencies”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"| Field | Required | Notes |
|---|---|---|
app_id | yes | Slug of the target app |
routes | no | Documentation-only list of <METHOD> <PATH> entries the activation UI shows the tenant (“This app will call …”) |
reason | no | One-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.
| Field | Required | Notes |
|---|---|---|
name | yes | Lowercase alphanumeric + underscores, 3–64 chars |
description | yes | One-line; shown to the agent’s planner |
scopes_required | yes | Subset of top-level required_scopes |
agent_visible | no, default true | If false, only your own handlers can call it |
lifecycle
Section titled “lifecycle”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.
| Field | Type | Notes |
|---|---|---|
on_install | bool | Fires on first activation |
on_uninstall | bool | Fires on deactivation |
on_inbound | bool | Subscribes to tenant inbound channel events (opt-in fan-out) |
on_user_added | bool | Fires when a new user joins the tenant |
schedules[] | list | Named cron entries; minute precision |
lifecycle.schedules[]
Section titled “lifecycle.schedules[]”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.
workflows
Section titled “workflows”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.
| Field | Required | Notes |
|---|---|---|
id | yes | Lowercase slug, unique within a manifest, ≤64 chars |
name | yes | Short label shown to the orchestrator and audit log |
intent | yes | One sentence: when does this workflow apply? |
flow | yes | Ordered list of steps (1–50, each ≤500 chars) — pseudo-code or natural-language |
triggers | no | Up to 20 phrases the orchestrator should match against the user’s request |
non_triggers | no | Up to 20 explicit negative cues — “NOT when X” |
specifics | no | Free-form notes for the executing agent (≤2000 chars): defaults, edge cases, output style |
tools_required | no | Tool names the flow needs. Workflows with unmet requirements get filtered out before reaching the orchestrator’s prompt. |
channels | no | Restrict 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.
agent / agents (multi-agent)
Section titled “agent / agents (multi-agent)”Optional. Declares this app’s specialist agent(s). Mutually exclusive — pick one form:
agent:(singular) — one specialist, simple caseagents:(plural) — up to 8 specialists, exactly one markedis_default: true. Pick at call time viactx.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).
| Field | Required | Notes |
|---|---|---|
id | yes (plural form), ignored (singular) | Lowercase letter-prefixed slug. The platform fills in default for the singular form. |
name | yes | Display name shown in the chat orchestrator’s catalog. ≤80 chars |
system_prompt | yes | The specialist agent’s system prompt. Workflow flow + specifics are appended at request time. ≤20 000 chars |
allowed_skills | no | Optional skill-name allow-list scoping which platform skills the agent’s tool calls reach. Null = no restriction (per-app scope gates still apply). |
tools_allowed | no | Per-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_default | no | Plural 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_hints | no | Free-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.
agent.vision
Section titled “agent.vision”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.
| Field | Required | Notes |
|---|---|---|
text | yes | What the vision pursues. ≤4000 chars. The vision-loop reads this as the top-level goal statement. |
success_criteria | no | List of plain-language criteria (≤20). Surfaces in the REVIEW phase as completion checks. |
autonomy_mode | no | supervised (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_goals | no | Cap on goals the loop can spawn. Default 50, max 500. |
budget_max_cost_cents | no | Optional 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: 100install_settings
Section titled “install_settings”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.
| Field | Required | Notes |
|---|---|---|
id | yes | Lowercase slug. Becomes the key in ctx.config. |
type | yes | One of string, text, number, boolean, select, select_or_custom, project_picker, secret |
label | yes | Shown to the tenant in the consent form |
description | no | Helper text under the input |
required | no | Default false. Required fields block activation if missing. |
default | no | Pre-filled value (must match the declared type) |
options | no | For type: select / select_or_custom — list of allowed values |
min / max | no | For type: number — numeric range |
expose_to_bundle | no | Default 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_default | no | Platform 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: trueReading values at runtime:
@app.on_inboundasync 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.
http_routes
Section titled “http_routes”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.
| Field | Required | Notes |
|---|---|---|
path | yes | Path 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. |
method | yes | One of GET, POST, PUT, DELETE, PATCH (uppercase or lowercase — normalised to uppercase). |
public | no, default false | When 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 generatedThe 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.
chrome
Section titled “chrome”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| Field | Required | Notes |
|---|---|---|
panel.enabled | no, default false | Must be true to render the panel |
panel.default_tab | no | id of the initially active tab |
panel.width | no, default 400 | Panel width in pixels (280–720) |
panel.tabs[] | no, default ["chat"] | Up to 8 entries; strings (built-in) or objects (custom) |
Custom tab object fields:
| Field | Required | Notes |
|---|---|---|
id | yes | Lowercase slug, unique within the manifest |
label | yes | Tab title (≤40 chars) |
icon | no | lucide icon name (layout, message, folder, settings) |
path | no | Path under bundle URL; defaults to /panel/<id>.html |
agents[].team
Section titled “agents[].team”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.
agents[].heartbeats[]
Section titled “agents[].heartbeats[]”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.rag_slots
Section titled “rag_slots”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.
emits + subscribes_to
Section titled “emits + subscribes_to”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.
# Emitteremits: [lead_qualified, post_published]
# Subscribersubscribes_to: - emitter_app: marketing event_name: lead_qualified target_agent: bdr # invoke this agent on receipt # OR target_heartbeat: <slug> to wake a heartbeat insteadValidators reject:
- Duplicate event names in
emits subscribes_to.target_agentreferencing an undeclared agenttarget_heartbeatreferencing 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.
Validation behavior
Section titled “Validation behavior”- 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.