Cross-app events
If app A wants app B to do something when X happens, the right
answer is rarely “let A call B’s API directly”. Use the platform’s
event wires: app A emits a named event, app B subscribes_to
it, and the wire fires when both tenants have approved the
connection.
This is the same machinery that lets demo-marketing qualify a lead
and have demo-sales start an outreach cadence — without either
app knowing the other’s internals.
When to reach for this
Section titled “When to reach for this”| Need | Pattern |
|---|---|
| App A wants App B to handle data downstream | emits + subscribes_to wire (this page) |
| App A wants a synchronous answer from App B | ctx.apps.invoke + bilateral grant |
| App A wants to coordinate humans + multiple agents in a thread | ctx.rooms.post |
| Within one app, master agent → specialist | ctx.team.delegate |
| React to channel-level inbound (email, WhatsApp, voice) | Reacting to emails — NOT this pattern |
See cross-app reference for the
full API map. This page focuses on the event style (pub/sub,
fire-and-forget) — for synchronous RPC use ctx.apps.invoke.
Canonical solution
Section titled “Canonical solution”1. Emitter app declares the event
Section titled “1. Emitter app declares the event”# linkworld.app.yaml — the emitteremits: - lead_qualified - quote_signedEvent names are ^[a-z][a-z0-9_]*$ — lowercase, underscore-separated,
no dots. Max 30 events per app.
# In the emitter's handlerawait ctx.events.emit("lead_qualified", { "from": env.sender_name, "score": 85, "thread_id": env.channel_thread_id, "reason": "asked about pricing for 200 seats",})Payload must be JSON-serializable. The platform doesn’t transform
it — whatever you emit, the subscriber’s handler receives verbatim.
2. Subscriber app declares the subscription
Section titled “2. Subscriber app declares the subscription”# linkworld.app.yaml — the subscribersubscribes_to: - emitter_app: demo-marketing event_name: lead_qualified target_agent: bdr # which agent in *this* app handles it # target_heartbeat: morning_review # OR wake a heartbeat insteadtarget_agent must be a declared agent in this app. Optional
target_heartbeat wakes a specific heartbeat on event arrival
instead of invoking the agent — useful when the reaction is part
of a batched routine.
3. Subscriber implements the handler
Section titled “3. Subscriber implements the handler”from linkworld_sdk import AppEvent
@app.on_eventasync def on_event(ctx: Context, ev: AppEvent) -> None: if ev.event_name != "lead_qualified": return if ev.emitter_app_id != "demo-marketing": return # ignore other emitters (defense in depth)
lead = ev.payload await ctx.rooms.post( shared_room_id, f"✅ Picking up qualified lead {lead['from']} (score {lead['score']})", from_agent="bdr", # required: must match an agent in your app ) await ctx.commitments.add( f"First touch: reach out to {lead['from']}", due_in_hours=4, )app.onEvent(async (ctx, ev) => { if (ev.event_name !== 'lead_qualified') return if (ev.emitter_app_id !== 'demo-marketing') return // … react to ev.payload})4. Tenants approve the wire (bilateral)
Section titled “4. Tenants approve the wire (bilateral)”The manifest entry is just a declaration — “I’m willing to consume this if approved”. The actual wire requires both tenant admins to opt in via workspace-control. Until they do, no events flow.
This is the platform’s privacy guarantee: an emitter can’t push data to an unwilling subscriber, and a subscriber can’t tap into an emitter that didn’t authorize it.
Activation auto-creates + auto-approves the bilateral grant in
linkworld_app_event_wires for entries whose target app is also
installed and has matching emits. For everything else: admin
approval.
5. Chain-depth protection
Section titled “5. Chain-depth protection”The platform tracks X-LW-Chain-Depth headers and rejects events
that would extend a chain beyond depth 8. A visited-set on the
platform side detects cycles (A emits → B emits → A emits → ...)
and breaks them. You don’t write defensive code for this — the
platform fails the emit call with a clear error.
❌ Anti-patterns
Section titled “❌ Anti-patterns”Don’t: call another app’s HTTP endpoint directly
Section titled “Don’t: call another app’s HTTP endpoint directly”# ❌ DON'T do thisawait httpx.post( f"https://app-b.example.com/webhook", json={"lead": data}, headers={"Authorization": f"Bearer {token}"},)Why it’s wrong:
- You bypass the platform’s audit log — there’s no record of A talking to B.
- You bypass tenant consent — the tenant admin never approved A→B data flow.
- You hardcode the recipient’s URL — if app B migrates, A breaks.
- You hand-roll auth — every breakage there is a security hole.
- The platform’s chain-depth + cycle-detection can’t see your call.
Always go through ctx.events.emit (for pub/sub) or ctx.apps.invoke
(for RPC).
Don’t: invent a transport in your own app records
Section titled “Don’t: invent a transport in your own app records”# ❌ DON'T do this# App A writes to a magic "inbox" record_type that App B pollsawait ctx.tools.call("write_app_records", record_type="cross_app_inbox", ...)You’ve rebuilt the wire system as a leaky polling abstraction. No
audit, no chain-depth, no consent flow, no event-name validation.
The fact that app_records are app-private would have prevented
this — if you found a way around it, you’re doing something the
platform explicitly designed against.
Don’t: emit without a stable event name
Section titled “Don’t: emit without a stable event name”# ❌ DON'T do thisemits: - lead_qualified_v2 - lead_qualified_v3Event names are part of your public API. Subscribers wire up against a specific name; if you rename it, every subscribed app breaks silently. Treat the event name like a database column name — once published, freeze it. Evolve the payload shape backwards-compatibly instead.
Don’t: cram unbounded state into the payload
Section titled “Don’t: cram unbounded state into the payload”# ❌ DON'T do thisawait ctx.events.emit("data_ready", {"rows": entire_query_result})Wire payloads have a size limit and travel over an HTTP boundary.
For large data: emit a reference ({"dataset_id": "..."}) and
let the subscriber call ctx.apps.invoke or read shared records
to fetch what it needs.
When this doesn’t apply
Section titled “When this doesn’t apply”- Inbound channels (email, WhatsApp, voice, web). Those use
on_inbound, noton_event. See Reacting to emails. - Same-app collaboration. If the work stays within one app’s
agents, use
ctx.team.delegate— it’s lighter and doesn’t need the wire/grant approval flow. - You need a synchronous answer.
ctx.events.emitis fire-and-forget. For “ask app B, wait for its answer, continue”, usectx.apps.invokeinstead — bilateral grant, same auth flow, but request/response semantics.
Related
Section titled “Related”- Cross-app calls reference —
the three APIs (
team.delegate,apps.invoke,events.emit) with full signatures. - Cross-app handoff tutorial —
end-to-end runnable demo with
demo-marketing+demo-sales. - Wires & grants (platform) — what the tenant admin sees when approving.
- Reacting to emails — the channel-inbound cousin (different mechanism).