Skip to content

Python SDK reference

from linkworld_sdk import (
App,
Context,
InboundEnvelope,
MockSecrets,
MockTools,
ToolCallError,
load_manifest,
load_manifest_from_string,
)
app = App.from_manifest("linkworld.app.yaml")
@app.tool("classify", description="...", scopes_required=["mail.send"])
async def classify(ctx: Context, text: str) -> dict:
return {"category": "tax"}
@app.on_install
async def on_install(ctx: Context) -> None: ...
@app.on_uninstall
async def on_uninstall(ctx: Context) -> None: ...
@app.on_inbound
async def on_inbound(ctx: Context, env: InboundEnvelope) -> None: ...
@app.on_user_added
async def on_user_added(ctx: Context, user: dict) -> None: ...
@app.on_schedule("daily-summary") # name from manifest.lifecycle.schedules
async def daily(ctx: Context) -> None: ...

Registration rules:

  • Handler must be async def. Sync handlers raise TypeError at registration.
  • scopes_required must be a subset of manifest.required_scopes. Mismatch raises ValueError.
  • @app.on_X requires manifest.lifecycle.X = true. Mismatch raises ValueError.
  • Schedule names must match manifest.lifecycle.schedules[].name.
  • Double-registration of the same hook raises ValueError.
app.run() # production: HTTP server on manifest.runtime.port
app.run(local=True) # local: same shape + Mock backends

Production mode reads LINKWORLD_MCP_URL + LINKWORLD_MCP_TOKEN from container env (the platform injects them at provision time). Local mode swaps in MockTools + MockSecrets and exposes /__mock/tool and /__mock/secret endpoints for direct curl.

Request-scoped helper passed to every handler.

ctx.tenant_id # str
ctx.app_id # str — your slug
ctx.event_type # 'tool' | 'on_inbound' | …
ctx.tools # ToolsApi
ctx.secrets # SecretsApi
ctx.user_id # Optional[str]
result = await ctx.tools.call(
"email_send",
subject="Hello",
body="",
)

Returns the platform’s result dict. Raises ToolCallError on:

decisionMeaning
scope_deniedYour app or the tenant didn’t grant the required scope. exc.needed_scopes lists which
network_errorCouldn’t reach the platform (rare; container is in the same VM)
platform_error5xx from the platform — transient
bad_responsePlatform returned non-JSON
deniedGeneric denial (rate-limit, gate decision, …)
api_key = await ctx.secrets.get("OPENAI_KEY")
if api_key is None:
raise RuntimeError("Tenant did not configure OPENAI_KEY")

Returns str | None. Never raises on auth/network failure — fail-closed. Key must match ^[A-Z][A-Z0-9_]{0,63}$ (validated SDK-side).

class ToolCallError(Exception):
decision: str
needed_scopes: list[str]
tool_name: str

For local dev:

tools = MockTools()
tools.register("email_send", lambda **kwargs: {"sent": True})
secrets = MockSecrets({"OPENAI_KEY": "sk-test"})

You don’t normally instantiate these directly — app.run(local=True) attaches them automatically. The /__mock/tool and /__mock/secret HTTP endpoints in local mode are sugar over the same classes.

@dataclass
class InboundEnvelope:
message_id: str
channel: str # 'email' | 'whatsapp' | 'webhook' | …
from_: str # alias for `from` (Python keyword)
body: str
subject: Optional[str]
received_at: str # ISO-8601 UTC
attachment_ids: list[str]
metadata: dict
load_manifest("linkworld.app.yaml") # path → Manifest, or ManifestError
load_manifest_from_string("apiVersion: …") # string → Manifest, or ManifestError

Both raise ManifestError (a wrapper around Pydantic’s ValidationError) on parse or schema failure.