Your App Needs Another App: Call It, Queue It, or Wake Its Agent?
App A needs something from app B. It can call an API, publish a durable message, enqueue a job, submit an event to app B's conversation, invoke an MCP tool, or mount app B's widget. Every path moves data. They disagree on what completion means, what survives a worker failure, and where authority is checked.
The first version of cross-app integration often begins with a URL. App A needs a report, so it posts to app B. Later the report takes two minutes, the request dies with one worker, a browser socket becomes an accidental queue, or app A starts copying a user's bearer because the target is “internal.”
The problem is not connectivity. The problem is that “call another app” hides several different contracts.
KDCube makes those contracts separate. An immediate result uses a local app operation. A provider-neutral object vocabulary uses a named service. Durable domain work uses Data Bus. Ready background work uses the job stream. An agent turn enters the conversation lane. MCP and REST remain real protocol surfaces. A browser experience composes app-owned widgets.
Choose the semantic contract first; choose the shortest supported transport for that contract second.
This article starts where KDCube Application Integrations leaves off. That article maps what an app provides and consumes. This one asks how app A should actually use a capability owned by app B.
Current code and descriptors still use bundle in literal names such as bundle_id, bundles.yaml, @bundle_entrypoint, and /api/integrations/bundles/.... In builder-facing prose, that deployable unit is an app.
00Implementation highlights
| Concept | The takeaway |
|---|---|
| Completion semantics | Decide whether app A needs admission, a completed result, durable ownership, or an ordered agent turn. |
| Local operation | A bounded same-KDCube request can call app B's declared @api operation without an HTTP round trip. |
| Named service | App A can use app B's owner-defined object vocabulary without hard-coding its route or storage. |
| Durable work | Data Bus, background jobs, and conversation ingress survive beyond the initiating request, but they complete in different ways. |
| MCP and REST | Protocol compatibility remains a real network contract, even when both apps run in one KDCube. |
| Authority | Identity continuity supplies facts; app B still decides whether this caller may perform this operation. |
| Cross-KDCube boundary | Another deployment authenticates its own accepted proof and constructs a new local request context. |
01One phrase hides nine different contracts
Suppose app A needs app B to rename an issue, generate a report, find an object, notify an agent, and show an editor. Calling all five “API requests” erases the behavior that matters.
| App A's actual need | The contract that fits |
|---|---|
| Return one bounded result now | local app operation |
| Stream a file or byte response now | local streaming operation |
| Search and act through app B's stable domain vocabulary | named service |
| Apply a durable mutation after app A exits | Data Bus |
| Give app B ready background work | job stream |
| Start or continue app B's ordered agent work | conversation ingress |
Preserve MCP discovery and tools/call | MCP |
| Preserve HTTP behavior or cross a deployment boundary | REST |
| Put app B's interface inside a scene | widget composition |
The decisive question is not “How can I reach app B?” It is “What must be true when app A considers this operation complete?”
Choosing the wrong path can still produce a working demo. The mismatch appears under failure:
| Mismatch | What eventually breaks |
|---|---|
| A browser relay carries backend work | a disconnect becomes lost work |
| An HTTP request waits for durable processing | worker lifetime and timeout become part of the domain contract |
| Data Bus answers a cheap immediate query | the caller inherits at-least-once delivery and correlation it did not need |
| App A calls app B's agent loop directly | ordered conversation admission and durable turn state are bypassed |
| An internal URL is treated as permission | reachability is mistaken for authority |
| Every internal function becomes MCP | protocol overhead appears where no MCP consumer contract exists |
02Immediate result: call the declared operation
The smallest honest cross-app integration is one declared operation and one request-bound call.
App B exposes a bounded operation:
from typing import Anyfrom kdcube_ai_app.infra.plugin.bundle_loader import api class TaskTrackerEntrypoint: @api(method="POST", alias="issue_get", route="operations") async def issue_get( self, *, issue_id: str, **request_fields: Any ) -> dict[str, Any]: return await self.issues.get_visible_issue( issue_id=issue_id, request_fields=request_fields, )
App A calls the alias through the local operation bridge:
from kdcube_ai_app.apps.chat.sdk.infra.bundle_operations import ( call_bundle_operation,) result = await call_bundle_operation( bundle_id="task-tracker@1-0", operation="issue_get", route="operations", http_method="POST", data={"issue_id": "BUG-123"},)
This is not an import of app B's implementation. The runtime resolves app B's current deployed version and effective descriptor, checks that the app and operation are active for the bound caller, creates app B's request context, and then invokes the operation locally.
The distinction matters. App A does not pass a browser cookie, provider token, role list, or invented user_id as proof. The platform-controlled bridge projects the current caller facts into app B's context. App B still owns domain authorization, including whether that person may read BUG-123.
The same bridge has a stream variant for files and bytes:
from kdcube_ai_app.apps.chat.sdk.infra.bundle_operations import ( call_bundle_operation_stream,) result = await call_bundle_operation_stream( bundle_id="reporting@1-0", operation="report_download", data={"report_id": "report-2026-08"},) async for chunk in result.chunks: await consume(chunk)
The result carries the media type, filename, headers, and status alongside the chunks. A large report stays a stream instead of becoming base64 inside JSON.
The local operation caller exists inside a KDCube-bound request or task. A free-standing process has no bound caller. A call to an app in another KDCube also cannot use this bridge; it enters the target through an authenticated network surface.
03Stable domain language: ask a named service
A direct operation is right when app A knows the operation it wants. A named service is right when app B owns a domain that several apps, agents, and widgets should explore through one stable vocabulary.
For a task realm, that vocabulary may include:
provider.aboutIdentify the provider and the realm it owns.provider.capabilitiesDiscover the operations and object kinds currently available.object.searchFind objects through the provider's own search semantics.object.schemaInspect the schema before constructing an action.object.getResolve one provider-owned object reference.object.actionAsk the provider to apply a declared domain action.App B owns the namespace, object refs, schemas, search behavior, actions, connected-account requirements, and authorization. App A declares which namespace operations its agent, UI resolver, or service may use. Discovery finds the current owner.
Request-bound app code can ask for the namespace rather than pinning app B's route:
from kdcube_ai_app.apps.chat.sdk.solutions.named_services_providers import ( NamedServiceEndpoint, call_named_service_endpoint,) response = await call_named_service_endpoint( NamedServiceEndpoint(namespace="task"), { "operation": "object.search", "namespace": "task", "query": "blocked authentication issues", "limit": 20, },)
Discovery selects a configured same-KDCube bridge:
bundle_registryLoad app B and call its named_services() registry.bundle_operationCall app B's @api(alias="named_service") facade.moduleCall an explicit provider module in the same runtime.The path can change without changing the namespace contract. That is the point: app A depends on task semantics, while app B remains the owner that parses refs and authorizes operations.
Generated code uses the same capability through a trusted relay. The restricted executor sends a named-service request to the supervisor-side path; provider credentials and storage handles stay in trusted services.
04Work that must survive: choose the durable lane
Some work should outlive app A's current request. KDCube has three durable paths because they represent three different owners.
Data Bus: app-owned domain work
Data Bus carries an app-scoped JSON command or event to app B's @data_bus_handler. The stream survives process loss. Workers claim and reclaim messages. serial_per_partition can prevent concurrent handlers for one object while allowing parallel work across objects.
from kdcube_ai_app.apps.chat.sdk.runtime.comm_ctx import ( data_bus_publish_and_wait,) result = await data_bus_publish_and_wait( bundle_id="task-tracker@1-0", subject="task.issue.rename", object_ref="task:issue:BUG-123", message_id=f"task_issue_rename_{request_id}", idempotency_key=f"task_issue_rename_{request_id}", reply=True, payload={"issue_id": "BUG-123", "title": "Fix login"}, timeout_ms=20_000,)
publish_and_wait(...) waits for a correlated handler result. It does not turn the stream into exactly-once RPC. Delivery is at least once, so app B stores idempotency and revision decisions with its domain state.
Job stream: ready background work
Use the background job stream when the work is already ready and app B's one @on_job dispatcher owns it. The queue addresses app B by bundle_id; proc claims the job fairly and rebuilds its execution context.
This is a good fit for a render, import, export, or scheduled maintenance unit whose product identity is “work for app B,” rather than a domain event shared with several handlers.
Conversation ingress: ordered agent work
Use conversation ingress when app B's agent should receive the event as part of an ordered conversation. Submission reserves the event in the conversation lane and returns admission. A later worker runs the turn under app B's agent contract and durable checkpointer.
The caller does not wait for the agent's final answer as if it called a local function. The accepted event and the completed turn are separate moments.
The communicator sits beside these paths. It streams progress, results, and UI events to a connected browser. It is a live delivery channel, not the durable record that makes the work survive a disconnect.
05MCP and REST stay protocol surfaces
MCP is the right contract when app A or an external agent needs MCP discovery, tool schemas, and tools/call. REST is the right contract when HTTP status, headers, webhook shape, or an external integration boundary matters.
Those semantics do not disappear when both apps run in one KDCube.
app A MCP consumer -> descriptor-configured streamable HTTP endpoint -> private OpenResty address inside the deployment -> authenticate request and construct app B context -> app B @mcp surface -> current grant and domain checks
There is currently no app-facing call_bundle_mcp(...) shortcut parallel to call_bundle_operation(...). The MCP consumer speaks an MCP transport. If app A only needs one bounded internal result, the local operation bridge expresses that smaller contract directly.
Managed MCP also separates two values that often look interchangeable:
| Field | Meaning |
|---|---|
url | the transport destination the MCP consumer dials |
resource | the protected-resource identity stored on the delegated grant |
The target guard compares the observed request path with the configured resource pattern. A private transport URL therefore needs a resource pattern that matches the canonical target path. Changing the host without preserving that identity produces a resource mismatch, not silent access.
REST follows the same authority principle. The deployment can make app B's HTTP surface privately reachable from app A, but network reachability grants no product permission. OpenResty and the app router authenticate the request; app B applies the current endpoint and domain policy.
This is also why an “internal” call should not copy a browser cookie or a provider credential into app A's payload. Local bridges carry bound context. Protocol paths present a credential accepted by the target.
06Widgets compose interfaces, not backend authority
An app may own part of another app's browser experience. A scene can mount app B's widget while app A owns the surrounding layout.
scene host -> resolve configured app B + widget alias -> mount app B surface -> runtime config and authentication handshake -> widget calls app B's authenticated backend
The widget remains app B's interface. A scene command or object ref identifies what the browser wants to show or do; app B's backend resolves and authorizes the object.
Live communicator events can notify the widget that durable work progressed. The underlying API, Data Bus, named service, or conversation lane still owns the backend action.
07Identity crosses; authority is decided again
Same-KDCube local paths preserve the represented caller without pretending that app A's decision automatically authorizes app B.
Durable paths store actor and authority metadata in their envelopes so a later worker can rebuild the relevant context. A provider id, endpoint URL, object_ref, or stream address locates something. None is proof that the caller may use it.
08Another KDCube is another authority boundary
Two KDCube deployments do not share their local app registry, Data Bus, job stream, conversation lane, process context, or Connection Hub grant store.
app A in KDCube A -> HTTPS MCP or REST + credential accepted by KDCube B -> KDCube B ingress -> B authenticates the proof -> B constructs B-local request context -> app B enforces B-owned grants and domain rules
A shared identity provider can let both deployments recognize the same person in a browser. That browser identity does not authorize a server-side app call. KDCube B still decides what proof its protected app surface accepts.
The implemented baseline supports a remote call when app A already has a credential accepted by B. B also provides managed MCP/REST authorization, OAuth with PKCE, current grant checks, refresh, and revocation for compatible external MCP consumers.
The source-side adapter that would let a hosted agent in A automatically discover B's challenge, open B's consent, store a per-agent B credential, receive the passive result event, and retry is still design work. It is tracked in kdcube/kdcube#223. The current article does not present that demand-driven cross-KDCube flow as implemented.
09The builder's decision flow
Ask four questions in order. The first yes picks the lane; everything else belongs to the smallest contract there is.
Before calling app B, answer five questions:
- What does success mean to app A: admitted, completed, persisted, or delivered to an agent?
- Who owns retries and idempotency?
- Does the domain need a stable provider-neutral vocabulary?
- Does an external protocol need to remain visible?
- Which target context and current authority will app B enforce?
Once those answers are explicit, the code becomes smaller. The runtime does not need one universal inter-app transport because the product does not have one universal meaning of “call.”
10The short version
A platform with many apps needs more than connectivity between them. It needs each crossing to say what kind of work is happening.
Call a declared operation for an immediate result. Ask a named service for an owner-defined realm. Put durable domain work on Data Bus. Give ready work to the job stream. Enter the conversation lane when an agent should act. Keep MCP and REST as protocols when compatibility is the requirement. Compose widgets for browser experiences.
Then let the target app authorize the represented caller again.
Contract first. Transport second.