Cross-app calls
Three SDK APIs cover agent collaboration at increasing scope:
| Scope | API | Auth |
|---|---|---|
| Within one app | ctx.team.delegate | manifest team: list |
| Different app, sibling tenant | ctx.apps.invoke | bilateral grant in linkworld_app_grants |
| Pub/sub (one to many) | ctx.events.emit | bilateral wire in linkworld_app_event_wires |
All three carry chain-depth headers (X-LW-Chain-Depth, max 8) and
visited-set cycle detection on the platform side.
ctx.team.delegate (intra-app)
Section titled “ctx.team.delegate (intra-app)”Master agents (CMO, dispatcher) hand off subtasks to specialists in
the same app. The CALLER’s manifest must list the target slug in its
team: field — that’s how master agents grant themselves permission.
async def cmo_logic(ctx: Context, prompt: str) -> str: research = await ctx.team.delegate( "researcher", # target agent slug in same app "What are competitor X-followers numbers?", from_agent="cmo", # caller's slug — required ) draft = await ctx.team.delegate( "content_drafter", f"Write a tweet referencing this research: {research.text}", from_agent="cmo", ) return draft.text or "(no draft)"Manifest
Section titled “Manifest”agents: - id: cmo name: CMO system_prompt: You are CMO. is_default: true team: [researcher, content_drafter] - id: researcher name: Researcher system_prompt: You research. - id: content_drafter name: Content Drafter system_prompt: You draft posts.Validators reject:
teamreferencing undeclared agents- An agent listing itself in its team
- Duplicates within
team teamon the singularagent:block (no peers to delegate to)
Server-side enforcement
Section titled “Server-side enforcement”When ctx.team.delegate hits /api/mcp/agent-invoke:
- Look up the caller’s
routing_hints.team(denormalized at activation) - If team is missing → deny with helpful error
- If target slug not in team → deny
- If target slug == caller (self-invocation) → no team check
- Increment
X-LW-Chain-Depth; reject at depth > 8 - Visited-set cycle detection per request
Errors
Section titled “Errors”AgentCallError raised on:
- Missing
from_agent - Network error
- 5xx from platform
- Team membership denied
- Chain depth exceeded
- Cycle detected
try: resp = await ctx.team.delegate("researcher", "...", from_agent="cmo")except AgentCallError as e: ctx.logger.warning("delegate failed: %s", e.reason) # fallback: do it ourselvesctx.apps.invoke (cross-app RPC)
Section titled “ctx.apps.invoke (cross-app RPC)”Marketing’s CMO calls Sales’s BDR. Both apps must have approved a bilateral grant for this pair.
async def cmo_outbound(ctx: Context): try: resp = await ctx.apps.invoke( "sales", # target app slug "bdr", # target agent slug in that app "Status of Acme deal?", ) return resp.text except AgentCallError as e: # Inspect e.reason for "no grant", "pending callee approval", # "agent not in callee allowed list", etc. and tell the user # what to fix. return f"Couldn't reach sales/bdr: {e}"Grants
Section titled “Grants”Created in Workspace Control → Wires & Grants, or auto-created
when a manifest declares cross_app_dependencies (see below).
Bilateral:
- Caller side initiates → auto-approves caller side
- Callee side admin approves separately
- Callee admin sets
allowed_agents(which of their agents are exposed) - Either side can revoke
The apps.grants.check_grant function on the platform validates:
- Grant row exists for
(caller_app, callee_app)in this tenant - Both
caller_approved_atANDcallee_approved_atare set - Target agent slug is in the grant’s
allowed_agentslist
Failure messages tell the LLM which step is blocking — the Steward then surfaces the right action to the user (“create grant in #/wires”).
Out-of-the-box: declare deps in the manifest
Section titled “Out-of-the-box: declare deps in the manifest”Forcing tenants to manually approve every grant they need for a fresh install is a UX cliff. Declare your cross-app dependencies in the manifest and activation auto-creates + auto-approves the grant for you:
cross_app_dependencies: - app_id: office-assistant-sdk reason: "Quote-Lifecycle (Draft → Sent → PDF-Render) for OFFER_REQUEST." routes: - "POST /documents" - "POST /documents/{id}/transition" - "POST /documents/{id}/render-pdf"What happens at activation:
- Platform checks that each declared
app_idis installed (status=‘active’) in the tenant. Missing? Activation rejects withstatus='missing_app_dependencies'and the consent UI surfaces “Install<slug>first” with a deep-link. - For each installed dep, platform upserts a row in
linkworld_app_grantswith bothcaller_approved_atandcallee_approved_atset to the activation timestamp.allowed_agents=['__route__'](the marker for HTTP-route delegation; same shape manual approvals use). - Re-activation is idempotent — the upsert merges
allowed_agentsand tops up missing approval timestamps but never weakens an existing grant.
The routes and reason fields are documentation-only. The
activation UI shows them to the tenant as a “This app will call …”
disclosure. Tenants can revoke or narrow any auto-grant via
workspace-control like a manually-created one.
Calling cross-app HTTP routes (ctx.apps.fetch)
Section titled “Calling cross-app HTTP routes (ctx.apps.fetch)”For deterministic cross-app work (no LLM hop) call HTTP routes directly:
resp = await ctx.apps.fetch( "office-assistant-sdk", # target app slug "/documents", # path on the target method="POST", body={"type": "quote", "lines": [...]},)doc = resp["body"]Auth is the same bilateral grant ctx.apps.invoke checks (with
agent_slug='__route__'), so a manifest cross_app_dependencies
entry covers both APIs.
Cost attribution
Section titled “Cost attribution”Cross-app calls execute under the callee app’s app_id_scope —
the LLM cost lands in the callee’s bill. This matches the model
where the callee’s agent is doing the work.
Headers + chain depth
Section titled “Headers + chain depth”Same as ctx.team.delegate: X-LW-Chain-Depth propagated via
LINKWORLD_CHAIN_DEPTH env var, capped at 8 hops, visited-set
cycle detection.
ctx.events.emit (pub/sub fan-out)
Section titled “ctx.events.emit (pub/sub fan-out)”When an app finishes a workflow it emits a named event. The platform fans out to all approved subscriber wires.
async def on_lead_qualified(ctx: Context, lead_id: str): result = await ctx.events.emit("lead_qualified", { "lead_id": lead_id, "score": 87, "source": "x_outreach", }) ctx.logger.info( "fanout %d/%d (failures=%d)", result["dispatched"], result["wire_count"], result["failures"], )Returns {wire_count, dispatched, failures}. wire_count == 0
means nobody is subscribed (cheap no-op).
Manifest
Section titled “Manifest”Both sides declare:
# Emitter side (marketing)emits: [lead_qualified, post_published, kpi_target_reached]
# Subscriber side (sales)subscribes_to: - emitter_app: marketing event_name: lead_qualified target_agent: bdrSubscriber kinds:
target_agent: <slug>— invoke the agent on event arrival. The platform forwards the event into the subscriber container as a lifecycle dispatch; the SDK then routes it to a code handler registered with@app.on_event(event_name)(see below).target_heartbeat: <slug>— wake the heartbeat row (next_run = now()); the regular pre-filter + invoke flow runs on next tick
Subscriber handler — @app.on_event
Section titled “Subscriber handler — @app.on_event”The subscription only declares the routing; the actual reaction code
lives in your handler. The decorator name must match a
subscribes_to[].event_name from the manifest:
from linkworld_sdk import App, AppEvent, ToolCallError
app = App.from_manifest("linkworld.app.yaml")
@app.on_event("lead_qualified")async def on_lead(ctx, event: AppEvent) -> None: # event.event_name — "lead_qualified" # event.emitter_app_id — "demo-marketing" (or whoever fired it) # event.payload — whatever the emitter passed to ctx.events.emit # event.target_agent_slug — agent slug from manifest's subscribes_to entry sender = event.payload.get("from", "unknown") score = event.payload.get("score", 0) try: await ctx.commitments.add( title=f"Follow up: {sender}", content=f"Lead from {event.emitter_app_id}, score {score}", from_agent="bdr", due_at="+4h", ) except ToolCallError as exc: ctx.logger.warning("commitment failed: %s", exc)Rules:
- One handler per
event_nameper app. Multiple subscribes_to entries with the sameevent_name(different emitters) all fire the same handler —event.emitter_app_iddistinguishes them. - Handler must be
async def. Sync handlers are rejected at decoration time (silent-failure footgun in the asyncio runtime). - If the manifest declares a
subscribes_toforevent_namebut no@app.on_eventhandler is registered, the platform’s dispatch succeeds but the handler returns{ok: False, no_handler: True}— the wire fires, the app effectively no-ops, and the failure is visible in the audit log. - Test handlers in unit tests with
TestClient.simulate_event(event_name, payload, emitter_app_id=...).
Created in Workspace Control → Wires & Grants. Same bilateral pattern as grants — both sides must approve before fan-out happens.
Best-effort fan-out
Section titled “Best-effort fan-out”emit_event runs subscribers concurrently via asyncio.gather with
return_exceptions=True. One subscriber’s failure doesn’t poison
others. Failures are logged + surfaced in the result counts.
Cycle prevention
Section titled “Cycle prevention”Pub/sub events can theoretically loop (A emits X → B subscribes → B emits X → …). The platform doesn’t prevent this at the wire level (events are designed to fan out freely), but agent invocations under wires still go through the chain-depth + visited-set checks. If your event causes an agent to emit the same event, you’ll hit the chain cap quickly.
Headers
Section titled “Headers”Three headers carry chain context:
| Header | Set by | Used by |
|---|---|---|
X-LW-Chain-Depth | Caller (or platform on first hop) | Server: reject if > 8 |
X-LW-Visited-Agents | Caller | Server: cycle detection |
The SDK reads LINKWORLD_CHAIN_DEPTH and LINKWORLD_VISITED_AGENTS
env vars (set by the platform on nested calls) and propagates them
on outbound team / apps / events calls.
Sandbox / public-route stubs
Section titled “Sandbox / public-route stubs”On public HTTP routes (handlers behind @app.http_route(public=True)),
all three APIs are sandboxed:
ctx.team.delegateraises (no caller-agent identity on public routes)ctx.apps.invokeraises (no tenant principal to validate against grants)ctx.events.emitraises (emits would fire side-effects without a user authorizing)
If you need to do these from a public route’s handler, route the work through a private route or a tool first.
See also
Section titled “See also”- Platform-side: Wires & grants for the user-facing UI
- Heartbeats — heartbeats can also receive wire
events via
target_heartbeat - Group rooms — sometimes a persistent room is the right shape instead of a chain of grant calls