Concepts
Apps live in containers
Section titled “Apps live in containers”When a tenant installs your app, the platform provisions a container scoped to (tenant, app, version). The container wakes on demand when an event arrives (tool call, inbound message, schedule tick) and sleeps after 15 minutes idle. You don’t manage the container; the platform does.
Tenant A installs tax-bot v0.1.0 ──► container-A-tax-bot-0.1.0Tenant B installs tax-bot v0.1.0 ──► container-B-tax-bot-0.1.0 (separate)Tenant A installs tax-bot v0.1.1 ──► container-A-tax-bot-0.1.1 (separate)Per-tenant isolation is the default. Two tenants of the same app can’t see each other’s data.
Manifest declares the contract
Section titled “Manifest declares the contract”Every app starts with a linkworld.app.yaml:
apiVersion: linkworld.ai/v2app_id: tax-botversion: 0.1.0name: Tax Botruntime: image: ghcr.io/you/tax-bot:0.1.0required_scopes: [mail.send, files.read]tools: - name: classify_invoice description: Categorize text as invoice / receipt / payroll / other scopes_required: []lifecycle: on_inbound: true schedules: - name: daily cron: "0 8 * * *"The platform validates this at deploy. Unknown fields fail. The same schema is enforced by both SDKs at runtime.
See the full manifest reference.
Handlers run on events
Section titled “Handlers run on events”The SDK gives you four event surfaces:
| Hook | Fires when |
|---|---|
tool | The tenant agent invokes one of your declared tools |
onInbound | A tenant routes an inbound message to your app (opt-in fan-out) |
onSchedule | A cron entry from your manifest ticks |
onInstall / onUninstall / onUserAdded | Tenant lifecycle events |
Every handler gets ctx — a request-scoped helper:
ctx.tenantId // stringctx.appId // your slugctx.appVersion // your versionctx.tools.call(name, args) // call platform skillsctx.secrets.get(key) // read your encrypted secretsScopes are how the platform gates tool calls
Section titled “Scopes are how the platform gates tool calls”Your manifest declares required_scopes. The tenant grants those
on install. At runtime, every ctx.tools.call(...) is checked:
- If your app declared the scope and the tenant granted it → call succeeds
- If either is missing →
ToolCallError(decision='scope_denied')
try { await ctx.tools.call('email_send', { to, body })} catch (err) { if (err instanceof ToolCallError && err.decision === 'scope_denied') { // Degrade gracefully — tenant didn't grant mail.send return } throw err}Secrets
Section titled “Secrets”Two tiers:
- Dev-default — you seed it once, every tenant sees the same value (use for an LLM API key you pay for)
- Tenant-override — the tenant admin overrides per install (use when each tenant brings their own credential)
Tenant-override wins; falls back to dev-default; returns null if neither is set. Encrypted at rest with AES-256-GCM, never decrypted on the wire.
const apiKey = await ctx.secrets.get('OPENAI_KEY')if (!apiKey) { throw new Error('Tenant did not configure OPENAI_KEY')}Cost attribution
Section titled “Cost attribution”Every LLM call your app makes inside ctx.tools.call(...) is tagged
with your app_id in the platform’s metrics. Your dev console shows
total tokens + compute seconds per app per tenant. Don’t pay for
something you didn’t ship.
Trust boundaries
Section titled “Trust boundaries”| Boundary | What’s enforced |
|---|---|
| Tenant ↔ app | Per-(tenant, app, version) container; cross-tenant queries impossible at the DB layer |
| App ↔ platform tools | Scope checks on every call, audit log entry per call |
| App stderr | Goes to the dev console’s Logs tab — but stack traces stay out of HTTP responses |
| App secrets | Encrypted at rest, never displayed after creation, fail-closed on network error |
Read Trust boundaries for what these mean for your code.