Skip to content

Concepts

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.0
Tenant 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.

Every app starts with a linkworld.app.yaml:

apiVersion: linkworld.ai/v2
app_id: tax-bot
version: 0.1.0
name: Tax Bot
runtime:
image: ghcr.io/you/tax-bot:0.1.0
required_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.

The SDK gives you four event surfaces:

HookFires when
toolThe tenant agent invokes one of your declared tools
onInboundA tenant routes an inbound message to your app (opt-in fan-out)
onScheduleA cron entry from your manifest ticks
onInstall / onUninstall / onUserAddedTenant lifecycle events

Every handler gets ctx — a request-scoped helper:

ctx.tenantId // string
ctx.appId // your slug
ctx.appVersion // your version
ctx.tools.call(name, args) // call platform skills
ctx.secrets.get(key) // read your encrypted secrets

Scopes 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
}

Scope catalog →

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')
}

Secrets guide →

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.

BoundaryWhat’s enforced
Tenant ↔ appPer-(tenant, app, version) container; cross-tenant queries impossible at the DB layer
App ↔ platform toolsScope checks on every call, audit log entry per call
App stderrGoes to the dev console’s Logs tab — but stack traces stay out of HTTP responses
App secretsEncrypted at rest, never displayed after creation, fail-closed on network error

Read Trust boundaries for what these mean for your code.