Skip to content

Cross-app calls

Three SDK APIs cover agent collaboration at increasing scope:

ScopeAPIAuth
Within one appctx.team.delegatemanifest team: list
Different app, sibling tenantctx.apps.invokebilateral grant in linkworld_app_grants
Pub/sub (one to many)ctx.events.emitbilateral 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.

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)"
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:

  • team referencing undeclared agents
  • An agent listing itself in its team
  • Duplicates within team
  • team on the singular agent: block (no peers to delegate to)

When ctx.team.delegate hits /api/mcp/agent-invoke:

  1. Look up the caller’s routing_hints.team (denormalized at activation)
  2. If team is missing → deny with helpful error
  3. If target slug not in team → deny
  4. If target slug == caller (self-invocation) → no team check
  5. Increment X-LW-Chain-Depth; reject at depth > 8
  6. Visited-set cycle detection per request

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 ourselves

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

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:

  1. Grant row exists for (caller_app, callee_app) in this tenant
  2. Both caller_approved_at AND callee_approved_at are set
  3. Target agent slug is in the grant’s allowed_agents list

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:

  1. Platform checks that each declared app_id is installed (status=‘active’) in the tenant. Missing? Activation rejects with status='missing_app_dependencies' and the consent UI surfaces “Install <slug> first” with a deep-link.
  2. For each installed dep, platform upserts a row in linkworld_app_grants with both caller_approved_at and callee_approved_at set to the activation timestamp. allowed_agents=['__route__'] (the marker for HTTP-route delegation; same shape manual approvals use).
  3. Re-activation is idempotent — the upsert merges allowed_agents and 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.

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.

Same as ctx.team.delegate: X-LW-Chain-Depth propagated via LINKWORLD_CHAIN_DEPTH env var, capped at 8 hops, visited-set cycle detection.

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).

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: bdr

Subscriber 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

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_name per app. Multiple subscribes_to entries with the same event_name (different emitters) all fire the same handler — event.emitter_app_id distinguishes 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_to for event_name but no @app.on_event handler 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.

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.

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.

Three headers carry chain context:

HeaderSet byUsed by
X-LW-Chain-DepthCaller (or platform on first hop)Server: reject if > 8
X-LW-Visited-AgentsCallerServer: 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.

On public HTTP routes (handlers behind @app.http_route(public=True)), all three APIs are sandboxed:

  • ctx.team.delegate raises (no caller-agent identity on public routes)
  • ctx.apps.invoke raises (no tenant principal to validate against grants)
  • ctx.events.emit raises (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.

  • 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