KDCube is built around apps

Everything you ship on KDCube lives inside an app. The app is the single unit of deployment, versioning, tenancy, and lifecycle — every other part of the platform exists to run apps, isolate them, meter them, stream their output, expose their object namespaces, and mount their UI surfaces. When you install KDCube, you are installing the runtime that hosts apps; when you build "an agent," you are authoring an app. The SDK includes both the built-in ReAct subsystem and a separate ISO runtime subsystem that apps can use for controlled execution.

Surfaces (Widget, Copilot, API, Pipeline, Dashboard, MCP, named-service provider) compose an App. One or more apps compose a product. KDCube is the runtime that hosts every app.

That single decision shapes most of the SDK's ergonomics:

  • One authoring surface for the whole application. An app carries Python backend code, optional TypeScript UI widgets and views, tools, event sources, skills, outbound MCP tool integrations, optional app-served MCP endpoints, named-service providers, prompts, configuration, hosted file outputs, and storage layout — all in one package, versioned together.
  • Hot-loadable without restarting the runtime. Apps can be replaced or upgraded in place; the ingress and processor services stay up. This is what makes "deploy" a small operation, not a downtime event.
  • Tenant-scoped by construction. An app runs against a (tenant, project, user) context the platform resolves at admission. Storage paths, budgets, decision logs, and streaming channels inherit that scope — the app doesn't have to enforce it manually.
  • Metered and billed as a unit. Every model call, tool invocation, and storage write routed through the app is tagged for economics accounting, so per-tenant spend, audit, and rate limits apply without extra plumbing.
  • Framework-agnostic inside. An app can use the built-in ReAct v3 loop, LangGraph, CrewAI-authored flows, the separate ISO runtime for controlled execution, plain Python, or no LLM at all. The platform guarantees the surrounding runtime; what happens inside the app is your choice.

Environment rule: one tenant/project is one isolated deployment environment. Use a different tenant/project when you need customer isolation or separate lifecycle stages such as dev, staging, and prod. Keep multiple apps inside one tenant/project when they belong to the same environment.

Terminology: KDCube now calls this unit an app. Internal identifiers and descriptors still use the older word bundle, for example @bundle_entrypoint, bundle_id, bundles.yaml, and /bundles/....

The rest of this page is a tour of how you actually build one: what an app contains, how it plugs into the platform, what the built-in primitives (storage, tools, app events, named services, hosted files, SDK integrations, widgets, APIs, @mcp, @cron, @on_job, @venv) look like, and how you deploy it.

What Is an App?

An app is your application package. It combines Python backend code with optional TypeScript UI widgets and views into a single deployable unit. The package becomes a KDCube app by subclassing the platform's base class (BaseEntrypoint or a derived variant) and registering with the current internal decorator @bundle_entrypoint.

An app is the end-to-end application unit inside one tenant/project environment. It can include backend execution logic, authenticated or public APIs, widget UI, main UI, scheduled jobs, background job handlers, named-service providers, deployment-scoped config, deployment-scoped secrets, and optional per-user state. One environment can host many apps at the same time.

App UI is a platform-served surface, not a special iframe concept. An app may expose widget UI with @ui_widget(...) and main UI with @ui_main / ui.main_view. KDCube builds and serves those assets through the integrations/static routes; a consuming frontend can render them directly or embed them, and the KDCube control plane often uses iframes for isolation. That iframe is a client display choice, not an app object.

App code can be delivered either from git or from a mounted local path. In deployed environments the registry usually points at a git repo, ref, and subdirectory. In local descriptor-driven development, use kdcube init --tenant T --project P --descriptors-location ... to stage a concrete runtime under the platform default base (~/.kdcube/kdcube-runtime/<tenant>__<project>/), then iterate with kdcube bundle reload <bundle_id> --tenant T --project P. Rebuild platform images later with kdcube refresh --tenant T --project P --build; add --path /path/to/kdcube-ai-app when refresh should copy a local platform checkout first, or add exactly one of --latest, --upstream, or --release <ref> when the existing runtime should move to another platform source while preserving descriptors. Reapply seed app descriptors with kdcube bundle config apply --descriptors-location ..., not with a full platform refresh.

Most example apps use the ReAct agent. The current runtime features ReAct v3, selected through AI_REACT_AGENT_VERSION=v3. The ReAct subsystem gives apps tools, skills, continuous conversations, a sources pool, ANNOUNCE, a signals board, named-service tools, tool lifecycle events, and an event timeline for tool calls, authored external events, followup, and steer. AI_REACT_AGENT_MULTI_ACTION=safe_fanout enables limited multi-action rounds in v3, but those actions still execute sequentially, not in parallel. The ReAct round cap can be set globally with ai.react.max_iterations / AI_REACT_MAX_ITERATIONS and overridden per app with config.react.max_iterations or react.max_iterations in app props. An app does not have to use ReAct at all: you can use any LangGraph graph, the ISO runtime as a separate subsystem, a simple request→response pipeline, or no LLM.

App Events, Policies, and Object Rehosting

App events are the authoring model for story-aware UI such as side chat, wizard, canvas, and snapshot flows. Tools are a special case of event sources: for a tool call, tool_id == event_source_id and tool_call_id == event_id. App UI can also submit authored external events, for example a saved wizard draft, uploaded evidence, canvas review request, or current snapshot notification.

OriginEvent identityTypical use
Tool callevent_source_id = tool_id
event_id = tool_call_id
Tool result becomes timeline blocks through event-source policies.
Widget or main UI eventpayload.external_event.event_source_id
payload.target.agent_id
Wizard, canvas, and chat events land on the selected agent lane.
Externally tracked artifact refext:... or another registered namespacereact.pull invokes the namespace rehoster and returns ordinary fi: paths.

Use explicit events for meaningful product transitions: saved draft, file attached, file deleted, assistance requested, canvas review requested, snapshot available, and chat message. Store fast-changing UI state through app APIs or app-owned storage, then send a compact event with a snapshot or artifact ref when the agent should see the state.

{
  "payload": {
    "target": {
      "agent_id": "default.react.agent",
      "story_kind": "case_wizard",
      "story_id": "case:draft-123"
    },
    "external_event": {
      "event_source_id": "case_workspace.wizard.assistance.requested",
      "story_id": "case:draft-123",
      "routing": {
        "reactive": true,
        "iteration_credit": 1
      },
      "data": {
        "section_id": "observed_behavior",
        "snapshot_ref": "ext:case-workspace/draft-123/snapshots/current.yaml"
      }
    }
  }
}

Event-source policies are registered by phase. The first implemented production family is block_production, which converts a tool or external-event result into timeline blocks and artifact rows. The same source can bind later phases such as timeline_projection, announce_production, and compaction_projection so the app controls how its events appear in visible context, ANNOUNCE, and compaction.

Named-service providers extend this pattern from events to objects. A provider can expose search scopes, object schema, object actions, default open effects, and block.produce/block.render policies. react.pull materializes the object into the ReAct artifact space while preserving meta.object_ref, so react.read and timeline projection can select the namespace-owned policies.

Custom artifact namespaces are resolved by registered namespace rehosters. A rehoster is declared in a loaded tool module or event module and must choose a destination that matches ReAct workspace semantics: snapshots go to fi:turn_<id>.snapshots/..., editable workspace files go to fi:turn_<id>.files/..., produced artifacts go to fi:turn_<id>.outputs/..., and domain attachments go to fi:turn_<id>.external.<event_kind>.attachments/<event_id>/.... After react.pull(paths=["ext:..."]), agents continue with the returned logical_path or physical_path rows.

i
Reference: the authoring contract is documented in bundle-events-README.md, with phase details in event-source-README.md and workspace/rehosting rules in ReAct Agent.

Example Apps

Start from the examples folder in sdk/examples/bundles/. The folder name still uses the internal term. The concrete reference app used on this page is versatile@2026-03-31-13-36 — a production-style sample that combines ReAct orchestration, app-local tools, widget decorators, REST operations, public endpoint examples, Telegram webhook and Mini App patterns, attachment handling, ReAct-stream-to-Telegram delivery, and custom UI. It is the current reference app for app authoring docs, but not the reference for @cron or @venv.

AppDescription
versatile@2026-03-31-13-36 Reference app for this page. Demonstrates ReAct orchestration, app-local and MCP tools, decorators for widgets and APIs, public endpoint examples, Telegram hook and webapp patterns, attachment handling, economics-aware entrypoint patterns, a custom main view under ui/main/, widget UI under ui/widgets/, and integrations operations that a real UI can call.

Platform Architecture for App Developers

See also: Platform Overview

KDCube is a multi-tenant AI application platform. It provides the infrastructure for deploying conversational AI agents at scale — with streaming, storage, economics, tooling, and extensibility all built in.

An app is your unit of deployment. It is a Python package, usually with optional UI sources, that plugs into the platform and defines how your AI agent thinks, what tools it uses, how it stores data, which object namespaces it exposes, what UI widgets it mounts, and how it bills users.

💡
Think of an app as a self-contained AI product slice — it can include its own agent logic, knowledge base, custom tools, UI dashboards, named-service providers, billing configuration, and more. The platform provides everything else: auth, routing, scaling, storage backends, buses, rate limits, and runtime isolation.

Big Picture: How It All Fits Together

Big picture platform overview diagram
Big Picture: How It All Fits Together Complete platform overview showing all components from client through ingress, processor, apps, storage, event and data buses, and integrations User Browser / App SSE + REST SSE/WS REST /bundles/{t}/{p}/{b}/operations/{op} Ingress Auth & Rate Limit App Routing SSE Emitter Task Enqueueing enqueue Redis Queue Task Buffer dequeue Processor (proc) App Loader LangGraph Exec Communicator (async) Operations API (REST) GET/POST /bundles/{t}/{p}/{b}/operations/{op} invoke chat·steer·ops·jobs YOUR APP Entrypoint Workflow Tools Skills ReAct Agent Widgets / UI Storage · Config · Economics Communicator Firewall Settings SSE stream Communicator (async streaming → client) PostgreSQL Conversations Redis Props / Cache S3 / Local FS Artifacts / Files Platform-managed persistence layer

Event Bus and Data Bus

Apps communicate with user-facing surfaces through two platform planes. The event bus carries discrete state changes: tool calls, tool results, protocol errors, external events, canvas updates, app-emitted notifications, and UI commands. The data bus carries heavier or replayable data: hosted files, materialized objects, search result payloads, canvas snapshots, streamed artifacts, and provider-owned object projections.

The processor is the trust boundary for both planes. It binds tenant/project/user context, applies app event-firewall policy before outbound events reach clients or recorders, resolves named-service providers for object actions, and stores durable artifacts through the configured storage backend. Ingress fans out permitted event-bus messages to connected browser clients; widgets and scenes can then render those events or fetch data-bus payloads by stable refs. The fuller bus diagram is in Platform.

Two Services, One Platform

Ingress (chat-ingress)

Handles inbound chat traffic. Authenticates users, enforces rate limits, validates the target app, enqueues the task, and opens an SSE stream back to the client. Your app rarely needs to know about this — the platform wires it up automatically.

Processor (chat-proc)

Dequeues tasks, loads your app entrypoint, and calls execute_core(). It also hosts the Operations API — the REST endpoint that your UI widgets call directly when no SSE stream is needed.

App Anatomy

An app is a Python package with a required entrypoint.py and optional supporting files:

App anatomy file structure diagram
App Anatomy File structure of a KDCube app package showing entrypoint, tools, skills, and config files my-bundle@1-0/ entrypoint.py REQUIRED — @bundle_entrypoint orchestrator/workflow.py BaseWorkflow subclass agents/gate.py Optional: custom intent gate (demo pattern) tools/my_tools.py Custom tool implementations tools_descriptor.py Register which tools are enabled skills/product/kdcube/SKILL.md Agent instruction sets skills_descriptor.py Which skills are visible to which agents knowledge/resolver.py Optional: docs/code knowledge space resources.py User-facing error messages event_filter.py Optional: filter/transform events

Minimal Entrypoint

from kdcube_ai_app.infra.plugin.bundle_loader import bundle_entrypoint, bundle_id
from kdcube_ai_app.apps.chat.sdk.solutions.chatbot.entrypoint import BaseEntrypoint
from langgraph.graph import StateGraph, START, END
from typing import Dict, Any

BUNDLE_ID = "my-bundle@1-0"

@bundle_entrypoint(name="My App", version="1.0.0")
@bundle_id(BUNDLE_ID)
class MyBundle(BaseEntrypoint):

    @property
    def configuration(self) -> Dict[str, Any]:
        return {
            "role_models": {
                "solver.react.v2.decision.v2.strong": {
                    "provider": "anthropic",
                    "model": "claude-sonnet-4-6"
                }
            }
        }

    async def on_bundle_load(self, **kwargs):
        # Called ONCE per process when bundle first loads.
        self.logger.info("App loaded!")

    async def execute_core(self, state, thread_id, params):
        graph = StateGraph(dict)
        graph.add_node("run", self._run_node)
        graph.add_edge(START, "run"); graph.add_edge("run", END)
        return await graph.compile().ainvoke(state)

    async def _run_node(self, state):
        await self._comm.delta(text="Hello from my app!", index=0, marker="answer")
        return {"final_answer": "Done", "followups": []}

BaseEntrypoint Lifecycle

Method / PropertyWhen CalledTypical Use
configuration (property)On app loadDeclare defaults: role_models, embedding, react, knowledge, economics, execution
on_bundle_load(**kwargs)Once per process, per tenant/projectBuild knowledge index, connect external services, warm caches
on_props_changed(...)When effective app props changedReconcile long-lived side effects after prop refresh
pre_run_hook(state)Before each turnPer-turn setup, state enrichment, request-local validation
execute_core(state, thread_id, params)Every turnBuild + invoke your LangGraph workflow
post_run_hook(state, result)After successful turn executionFast final bookkeeping after the main result
on_turn_completed(...)After completion, error, or cancellationCleanup that must run even when a turn fails
handle_job(**kwargs)When a background job is dispatchedReusable dispatcher for @on_job and mixin-owned background work
rebind_request_context(...)On cached singleton reuseRefresh request-bound state (comm, user, etc.)

Runtime hooks are normal entrypoint methods, not manifest decorators unless explicitly documented as decorators. If an app uses SDK mixins, call super() from completion and job hooks so mixin cleanup and background dispatch still run.

App Lifecycle

An app goes through a well-defined lifecycle from discovery to shutdown. Understanding these phases helps you place initialization logic, manage state, and handle configuration changes correctly.

Discovery and Loading

App source can come from git or from a mounted local path. The platform resolves the configured app source, imports the module, and finds the class decorated with @bundle_entrypoint. The loader extracts interface metadata (decorators for widgets, APIs, message handler, and background job handler) and builds an internal interface manifest that the REST layer and processor use for routing.

Singleton Instance Model

By default each incoming turn or operation creates a fresh entrypoint instance. When the registry sets singleton=true, the platform caches and reuses one instance per loaded app spec in the current process worker.

i
Even with singleton reuse, treat runtime memory as ephemeral. Request-bound surfaces (self.comm, current actor, conversation/turn ids) are rebound per invocation via rebind_request_context() and must never be cached across requests. Durable state belongs in app storage, Redis, or app props — not on self. If an app needs instance-local filesystem state, it should resolve that location through the platform storage helper instead of creating ad hoc runtime folders next to app source code. Singleton reuse is keyed by the loaded app spec (path + module), not by tenant/project and not by bundle_id alone.

Initialization Hooks

HookFrequencyTypical Use
on_bundle_load(**kwargs)Once per process, per tenant/projectBuild indexes, warm caches, clone repos, prepare local read-only assets, trigger UI build
on_props_changed(...)When effective props changed for the active instanceInvalidate prop-derived caches, mark helpers dirty, reconcile side effects after live prop updates
pre_run_hook(state)Every invocationLast-minute validation or reconciliation before execution
execute_core(state, thread_id, params)Every invocationMain app logic (chat turn or operation handling)
post_run_hook(state, result)Every invocationFinal bookkeeping after execution completes
on_turn_completed(...)Every invocation exit pathCleanup after success, error, or cancellation
handle_job(**kwargs)Background job dispatchHandle mixin-owned or app-owned work_kind values after proc claims a job
on_memory_reconciliation_request(request)Before a memory reconciliation job is stored/enqueuedValidate or augment one request's memory reconciliation controls
rebind_request_context(...)Singleton reuse onlyRefresh request-local handles on cached instance before the current call

on_bundle_load() must be deterministic and idempotent. It runs before any request depends on the app and is the right place for heavy preparation work. Do not store request-local state there.

on_props_changed(...) is different: it runs after effective app props changed for the active instance. Use it for long-lived side effects such as prop-derived caches, sidecar wrapper state, or other runtime helpers that must track app props. Do not use it for request-local validation or heavy one-time install/build work.

on_memory_reconciliation_request(request) is available on memory-enabled entrypoints. Return None or a JSON-safe patch to merge into the request; return {"ok": false, "error": "...", "message": "..."} to reject the request before a job is created.

Portable App Call Context

bundle_call_context is the app-owned, request-scoped context room. The internal name remains for compatibility. Use it for JSON-safe metadata that must follow nested agents, tools, background handlers, and isolated runtimes without asking the model to pass those values as tool arguments.

from kdcube_ai_app.apps.chat.sdk.runtime.comm_ctx import (
    bind_current_bundle_call_context_patch,
    get_current_bundle_call_context,
    update_current_bundle_call_context,
)

# Visible for the rest of the current invocation.
update_current_bundle_call_context({
    "my_bundle": {"selected_mode": "lite"},
})

# Temporarily override one named agent role for one nested run.
with bind_current_bundle_call_context_patch({
    "role_models": {
        "my.named.agent": {
            "provider": "anthropic",
            "model": "claude-haiku-4-5-20251001",
        },
    },
}):
    await self.run_named_agent(...)

current = get_current_bundle_call_context()

Read it from self.comm_context.bundle_call_context or get_current_bundle_call_context() in entrypoints, APIs, widget operations, and @on_job handlers. In in-process tools, bundle_tool_context.scope()["bundle_call_context"] is a tool-side reader for the same room. Isolated Docker/Fargate runtimes restore the same context through the runtime globals snapshot.

This context is not durable storage. It survives the current execution graph and child runtime boundaries; it does not automatically survive a later request. If a later background job needs the same decision, store it in the job payload/metadata or another durable bundle-owned location, then rebind it when that job runs. PORTABLE_SPEC_JSON is platform-built; bundle-owned per-call data belongs in bundle_call_context.

bundle_call_context.role_models is a reserved request-scoped overlay interpreted by the model router. Precedence is current call context first, then effective bundle props role_models, then platform defaults.

Hot-Reload on Config Change

When you update an app's ref in bundles.yaml and re-apply, the platform detects the configuration change via a content hash of the app directory. The updated app is loaded in the current process without a service restart. on_bundle_load() runs again for the new version, and subsequent requests are served by the refreshed instance.

For props-only live updates, the active app instance refreshes effective props on the next invocation, and on_props_changed(...) fires only if the effective props actually changed. Already-loaded singleton apps in the current worker also receive that hook on live bundles.props.update events.

Shutdown and Cleanup

The platform does not expose a dedicated shutdown hook. Because app state should live in external storage (Redis, S3, local shared storage), process termination is safe by design. Transient per-invocation state in OUT_DIR / workdir is scoped to the request and cleaned up automatically.

Storage Surfaces Across Phases

Surface / ScopeRead / write APILive authority todayExampleExport / ejection path
Platform/global propsread: get_settings()
raw read: get_plain("...")
write: none from normal bundle code
Promoted runtime config assembled from env plus descriptor files such as assembly.yaml and gateway.yamlPorts, auth ids, storage backends, runtime path rootsOutside kdcube export; manage through deployment descriptors
Platform/global secretsasync read: await get_secret_async("canonical.key")
write: none from normal bundle code
sync helper remains for compatibility
Configured secrets provider; in local secrets-file mode this is secrets.yamlDeployment-wide API keys and auth secretsOutside kdcube export; manage through deployment secret workflows
bundle_props / bundle_prop(...)read: self.bundle_prop(...)
write: await set_bundle_prop(...)
Mounted writable bundles.yaml when present; Redis is the runtime cache; grouped descriptor docs are fallback only when no mounted file existsApp feature flags, cron config, model selection, UI configExported to bundles.yaml by kdcube export
App secretsasync read: await get_secret_async("b:...")
write: await set_bundle_secret(...)
sync helper remains for compatibility
Configured secrets provider; in local secrets-file mode this is bundles.secrets.yamlApp-scoped webhook secrets and shared API tokensExported to bundles.secrets.yaml when the provider/export flow can reconstruct app secrets
User app propsread: get_user_prop(...), get_user_props()
write: set_user_prop(...), delete_user_prop(...)
PostgreSQL <SCHEMA>.user_bundle_propsPer-user non-secret preferences and app stateNever exported to descriptors or app export
User app secretsasync read/write: await get_user_secret_async(...), await set_user_secret_async(...), await delete_user_secret_async(...)
sync helpers remain for compatibility
Configured secrets provider; in local secrets-file mode this is secrets.yamlPer-user secret material such as personal access tokensNever exported to descriptors or app export
Redis KV cachedeveloper-defined read/write keysRedis onlyLightweight distributed state, flags, small cachesNo descriptor export
App storage backendapp storage APIsS3 or local/file-backed storage, depending on deploymentPersistent business data and large generated outputsOutside descriptor export
Shared local storage (BUNDLE_STORAGE_ROOT)self.bundle_storage_root() or bundle_storage_dir(...)Host-local or shared instance-visible storageLarge local caches, cloned repos, indexes, mutable local workspaces, cron stateOutside descriptor export
OUT_DIR / workdirfilesystem read/writeCurrent invocation onlyTransient turn files, generated artifactsEphemeral; not exported
Hosted conversation filesret.artifact_type == "files" or host_files(...)Conversation store plus current-turn file event metadataUser-visible downloads and attachments produced by tools or isolated executionCurrent conversation artifact surface; not descriptor state

Only deployment-scoped app state belongs to app descriptors and app export. Platform/global deployment state and all user-scoped state stay outside kdcube export.

For isolated execution, Docker/Fargate supervisors receive descriptor payloads and materialize them before tool bootstrap. App tools therefore read the same platform props, platform secrets, app props, and app secrets through the normal SDK helpers, while generated code does not receive descriptor files or provider secret env. Set execution.runtime.descriptor_payload_scope: active_bundle when an app runtime should receive only its own bundles.yaml and bundles.secrets.yaml sections.

Reference docs: bundle-runtime-configuration-and-secrets-README.md, runtime-read-write-contract-README.md, how-to-configure-and-run-bundle-README.md.

await get_secret_async("b:...") resolves the current app from the bound runtime context. During normal app execution that context is already present; outside a bound request or app runtime, use a fully qualified secret path instead of b:. Use the async helpers in new app APIs, tools, cron handlers, and @on_job handlers; sync secret helpers are compatibility APIs for old sync-only code.

Node / TypeScript Backend Inside an App

If your application backend already exists in Node or TypeScript, the supported pattern is to keep the public KDCube app surface in Python and run the Node backend as an app-local sidecar.

That split is intentional:

  • Python app shell owns decorators, auth, role gating, app props, app secrets, and the public API / widget / MCP / cron contract.
  • Node backend owns internal domain logic behind a narrow route boundary.
my.bundle@1-0/
  entrypoint.py
  backend_src/
    package.json
    src/
      bridge_app.ts

The Python side starts the sidecar through ensure_local_sidecar(...). Current runtime behavior:

  • one sidecar instance per worker for the active loaded app spec and tenant/project scope
  • app code reload stops the sidecar and the next call starts it fresh
  • props-only updates do not proactively restart the sidecar at publish time
  • startup-config changes restart lazily on next use; live config can be pushed lazily through POST /__kdcube/reconfigure

This lets you wrap an existing Node backend without replacing the Python app shell. The app remains the KDCube application unit; Node is one internal implementation part of that app.

Reference docs: bundle-node-backend-bridge-README.md and node-backend-sidecar-README.md.

Interface: In & Out

📥 Inbound: ChatTaskPayload

Every turn arrives as a ChatTaskPayload (Pydantic). Key fields:

  • request.message — user's text
  • request.chat_history — prior messages
  • request.payload — arbitrary JSON (for REST ops)
  • actor — tenant_id, project_id
  • routing — conversation_id, turn_id, bundle_id
  • user — user_id, user_type, roles, timezone
  • continuation — follow-up or steer type

📤 Outbound: Chat Streaming

Your bundle streams back via the Communicator. The client receives ChatEnvelope events in real time over SSE or Socket.IO.

  • delta — streaming text chunks (thinking / answer)
  • step — tool calls, status updates, timeline events
  • complete — turn finished with final data
  • error — propagate errors cleanly
  • event — custom events (artifacts, reactions, etc.)

Using the Communicator

# Stream answer text
await self._comm.delta(text="chunk...", index=0, marker="answer")

# Announce a step
await self._comm.step(step="web_search", status="started", title="Searching the web...")
await self._comm.step(step="web_search", status="completed")

# Emit follow-up suggestions
await self._comm.followups(["Tell me more", "Show examples"])

# Final complete signal
await self._comm.complete(data={"answer": "..."})

Recording comm events and sending event batches

Bundles can record selected post-firewall communicator envelopes into a bounded, scoped buffer and send the batch to a configured event sink. Recording is additive and scoped: a workflow, API handler, MCP endpoint, job handler, or tool can add a JSON-serializable scope without replacing the outer scope.

async with self.comm.recording(
    EVENT_SELECTOR,
    scope={"owner": "workflow", "bundle": bundle_id},
    mode="replace",
    max_events=500,
    sink=event_sink,
    send_on_exit=True,
):
    await self.run_react(...)

Platform child tool runtimes, including TOOL_RUNTIME[tool_id] = "local", receive portable active scopes through COMM_SPEC. Child-added scopes are recorded in the child buffer, written to comm_recorded_events.json, merged by the host, and sent by the host sink. Sink callbacks are live Python objects and are not serialized into child runtimes.

Open the recording scope at the boundary that owns the invocation: @on_message workflows, @api methods, @mcp methods, and @on_job handlers can all record when a communicator is bound. @cron is normally headless, so use comm recording only when the cron path invokes a comm-bound flow; otherwise enqueue a job or persist durable operational facts directly.

Reference docs: bundle-event-recording-and-sinks-README.md, comm-recording-event-sinks-README.md.

REST Operations (for UI Widgets)

Your bundle exposes additional REST operations via the Operations API hosted by the Processor:

POST /api/integrations/bundles/{tenant}/{project}/{bundle_id}/operations/{operation}
GET  /api/integrations/bundles/{tenant}/{project}/{bundle_id}/operations/{operation}

The explicit bundle_id form is the preferred route. The processor resolves the target method from the bundle's decorator-discovered interface surface rather than assuming every operation is just workflow.run(). A default-bundle compatibility shortcut still exists at POST /api/integrations/bundles/{tenant}/{project}/operations/{operation}.

Runtime Availability And Visibility

Bundle availability and resource visibility are deployment-scoped bundle props. The whole bundle is gated through the canonical enabled.bundle prop resolved by the platform. APIs and widgets declare code defaults for user types and roles, and can declare prop paths that the Bundle Admin UI can override. Resource enabled flags are stored in bundle props/admin state; do not pass the removed enabled_config argument to @api or @mcp.

@bundle_entrypoint(
    name="Ops",
    version="1.0.0",
    allowed_roles=("kdcube:role:viewer",),
    allowed_roles_config="visibility.bundle.allowed_roles",
)
class OpsBundle(BaseEntrypoint):

    @api(
        alias="report",
        user_types=("registered",),
        user_types_config="visibility.api.report.user_types",
        roles_config="visibility.api.report.roles",
    )
    async def report(self, **kwargs):
        ...

    @ui_widget(
        alias="admin",
        icon={"lucide": "Settings"},
        user_types=("privileged",),
        roles_config="visibility.widget.admin.roles",
    )
    async def admin_widget(self, **kwargs):
        ...

    @mcp(alias="automation", transport_config="mcp.automation.transport")
    def automation_mcp(self, **kwargs):
        ...

    @cron(alias="news-sync", expr_config="jobs.news_sync.cron")
    async def news_sync(self) -> None:
        ...

The visibility dot paths are resolved against effective bundle props. Missing or invalid values fall back to the decorator defaults. Empty user-type or role selections are intentional overrides that mean no restriction for that selector. Cron jobs use expr_config; blank or disable values disable the job. MCP endpoint request authorization belongs to the bundle-served MCP app, not to user_types / roles on the @mcp decorator.

⚙️
Precedence: when enabled.bundle is falsy, bundle operations, widgets, and MCP endpoints return 404, and proc skips scheduled jobs for that bundle.

Because these values live in bundle props, they work with hot bundle reload and live props updates. This is the mechanism that lets Bundle Admin change bundle visibility, API/widget visibility selectors, resource enabled flags, and scheduled-job expressions without a redeploy.

Continuation Types

TypeDescription
regularNormal new message
followupUser clicked a suggested follow-up
steerUser is redirecting the ongoing turn

Classic vs. Interactive Turn Execution

ModeBehavior
Classic turn execution The turn runs to completion before the user can affect the next agent step. From the user perspective, input is effectively blocked until the turn finishes and emits its final result.
Interactive turn execution While a turn is running, the user can still contribute to the same conversation. followup and steer are written into the conversation event lane. A live React turn can consume them immediately: followup stays on the current turn, while steer redirects the current turn at the next safe checkpoint. Idle-turn handling is controlled by the reactive-event admission path rather than by bundle UI code.
↪️
Interactive execution uses the existing continuation kinds: followup and steer. A busy conversation accepts these into the shared event lane rather than dropping them. followup is a live “continue on this turn” input, while steer is a live “redirect this turn” signal. When React owns the turn it consumes these directly on the current timeline.

Long ReAct turns may also emit chat_compaction transport events with semantic type chat.compaction. These mark context compaction start/completion while the turn continues; clients should render them as progress/activity items, not as final answers.

Storage

KDCube is a distributed, multi-tenant system. Three storage tiers are available to your bundle:

Storage tiers diagram
Storage Tiers Three storage tiers available to bundles: cloud object storage, local filesystem, and Redis cache ☁️ Cloud Storage BundleArtifactStorage file:// or s3:// Survives restarts & scaling Per tenant / project / bundle Read/write arbitrary keys CB_BUNDLE_STORAGE_URL 📁 Local FS (Shared) bundle_storage_root() BUNDLE_STORAGE_ROOT env Per bundle version namespace Knowledge indexes, caches EFS in production pathlib.Path (filesystem) ⚡ Redis Cache self.kv_cache / self.redis In-memory, fast Bundle props & config Session state, rate limits Pub/sub for bundle updates aioredis client

Cloud Storage (BundleArtifactStorage)

from kdcube_ai_app.apps.chat.sdk.storage.bundle_artifact_storage import BundleArtifactStorage

storage = BundleArtifactStorage(
    tenant="my-tenant", project="my-project",
    bundle_id="my-bundle@1-0",
    storage_uri="s3://my-bucket"  # or file:///data/bundle-storage
)

storage.write("reports/latest.json", data='{"count": 42}')
content = storage.read("reports/latest.json", as_text=True)
keys = storage.list("reports/")

Local FS (Shared Bundle Storage)

# In entrypoint or workflow
root = self.bundle_storage_root()  # pathlib.Path
index_path = root / "knowledge_index"
index_path.mkdir(exist_ok=True)

Path namespaced: {BUNDLE_STORAGE_ROOT}/{tenant}/{project}/{bundle_id}/. Use this helper-resolved storage for local bundle state that should survive across requests on the same instance. Typical examples are knowledge indexes, cloned repos, cron workspaces, prepared local caches, or mutable subsystem roots such as _task_tracker or _knowledge_base_admin. Do not keep operational state next to the bundle source tree. In production, mount the shared instance-visible storage layer here; today that is commonly EFS.

Redis Cache

# Low-level Redis client (aioredis)
await self.redis.set("my:key", "value", ex=3600)
val = await self.redis.get("my:key")

# KVCache wrapper
await self.kv_cache.set("user_prefs", {"theme": "dark"}, ttl=86400)

Workflow Orchestration

The platform uses LangGraph for workflow orchestration. Your execute_core builds and invokes a StateGraph. For common patterns, use BaseWorkflow:

from kdcube_ai_app.apps.chat.sdk.solutions.chatbot.base_workflow import BaseWorkflow

class MyWorkflow(BaseWorkflow):
    def __init__(self, *args, bundle_props=None, **kwargs):
        super().__init__(*args, bundle_props=bundle_props, **kwargs)

    async def process(self, payload):
        scratchpad = self.start_turn(payload)
        try:
            react = await self.build_react(
                scratchpad=scratchpad,
                tools_module="my_bundle.tools_descriptor",
                skills_module="my_bundle.skills_descriptor",
                additional_instructions=self.bundle_prop("react.additional_instructions"),
            )
            result = await react.run(payload)
            self.finish_turn(scratchpad, ok=True)
            return result
        except Exception as e:
            self.finish_turn(scratchpad, ok=False); raise
🚀
Use BaseWorkflow for the quickest path. It wires up ConvMemories, TurnStatus, ContextRAG, ApplicationHosting, and gives you build_react() which assembles the full ReAct agent with all tools and skills resolved.

react.additional_instructions is now a first-class bundle prop. If present, pass it through build_react(...) so the bundle can append deploy-scoped prompt guidance to the React decision system prompt without copying or forking the base runtime prompt.

build_react() respects runtime selection through AI_REACT_AGENT_VERSION. The featured runtime is v3. If AI_REACT_AGENT_MULTI_ACTION=safe_fanout is enabled, accepted multi-action rounds are still executed sequentially. The base round cap resolves from bundle props config.react.max_iterations / react.max_iterations, then assembly/env ai.react.max_iterations / AI_REACT_MAX_ITERATIONS, then fallback 15.

BaseWorkflow Key Parameters

ParameterTypeDescription
conv_idxConvIndexConversation vector index for semantic search
storeConversationStoreFile/S3-backed conversation storage
commChatCommunicatorChat streaming channel for SSE or Socket.IO delivery
model_serviceModelServiceBaseLLM registry / router
ctx_clientContextRAGClientContext retrieval and RAG
bundle_propsDictBundle runtime configuration
graphGraphCtxOptional knowledge graph context

Simplest Agentic Workflow Pattern

A bundle can use any orchestration pattern — it is not required to use the ReAct agent. This is one common pattern that combines a Gate agent with the ReAct agent:

Agentic workflow pattern diagram
Simplest Agentic Workflow Pattern Workflow diagram combining a Gate agent with the ReAct agent for agentic task routing Simplest Agentic Workflow Pattern (optional gate + react layers) User Message Gate Agent (Optional layer) Intent classify Route decision ReAct Agent Decision loop Tool calls Self-planning Answer Gen (Optional layer) Final answer Follow-ups 📜 Timeline — streamed to client in real time tool_calls · answer · thinking · artifacts · plan blocks — all streaming

Tools System

ToolNamespaceDescription
web_searchweb_toolsNeural web search with ranking (Brave / DuckDuckGo)
web_fetchweb_toolsFetch + parse web pages (readability-enabled)
execute_code_pythonexec_toolsIsolated Python code execution — Docker (default), Fargate, or in-process per bundle config
write_pdfrendering_toolsGenerate PDF from Markdown + table of contents
write_pngrendering_toolsRender HTML/SVG to PNG image
write_docxrendering_toolsGenerate DOCX from Markdown
write_pptxrendering_toolsGenerate PPTX slide deck
write_htmlrendering_toolsGenerate standalone HTML artifact
fetch_ctxctx_toolsFetch artifacts by logical path (ar:, fi:, ks: addresses)
readreactreact.readLoad artifact into timeline (fi:/ar:/ks:/so: paths)
writereactreact.writeAuthor current-turn content into files/<scope>/... for durable workspace state or outputs/<scope>/... for non-workspace artifacts; stream to canvas/timeline_text/internal channel and optionally share as file. Internal writes are files by default; scratchpad=true adds a short inline react.note.
pullreactreact.pullExplicitly materialize same-turn, historical, cross-conversation, or registered custom namespace refs as local reference material. fi: refs map by convention; custom refs such as ext:... are resolved by a namespace rehoster, and the returned rows tell the agent the materialized fi: path. Pulled refs do not modify the active current-turn workspace.
planreactreact.planCreate / update / close the current turn plan (shown in ANNOUNCE)
patchreactreact.patchPatch an existing current-turn text artifact. Unified diffs are supported; full replacement is used when the patch body is plain file content. Display line numbers in previews are never part of patch content.
checkoutreactreact.checkoutConstruct or update the active current-turn workspace from ordered fi:...files... refs. mode="replace" seeds the workspace from scratch; mode="overlay" imports selected historical files into the existing workspace. It does not accept ext:, outputs, snapshots, or attachments directly.
memsearchreactreact.memsearchSemantic search in past conversation turns
rgreactreact.rgSearch materialized artifact files by name and text-like files by content regex; returns read-ready ranges for react.read.
hidereactreact.hideReplace timeline snippet with placeholder

App-local tools use @kernel_function from Semantic Kernel and are registered in tools_descriptor.py. Tool results should use the common {ok, error, ret} envelope. When a tool produces user-visible files, ret must use the strict file artifact protocol.

In ReAct, a tool call is also a special case of an event source: the tool invocation produces timeline blocks, may contribute source rows or hosted artifacts, and can participate in the same event-source policy plane as external UI/service events. Most custom tools can rely on the default tool-result block production policies; only tools that need custom timeline rendering, custom artifact rehosting, or custom phase behavior need extra event-source decorators.

# tools/my_tools.py
from typing import Annotated
import semantic_kernel as sk
from semantic_kernel.functions import kernel_function

class MyTools:
    @kernel_function(name="search", description="Search product catalog")
    async def search(self,
        query: Annotated[str, "Search query"],
        limit: Annotated[int, "Max results"] = 5
    ) -> str:
        # your logic here
        return "results..."
# tools_descriptor.py
TOOLS_SPECS = [
    # SDK built-in tool modules (installed package)
    {"module": "kdcube_ai_app.apps.chat.sdk.tools.web_tools", "alias": "web_tools", "use_sk": True},
    {"module": "kdcube_ai_app.apps.chat.sdk.tools.exec_tools", "alias": "exec_tools", "use_sk": True},
    # App-local tools ("ref" = path relative to app root, works in Docker too)
    {"ref": "tools/my_tools.py", "alias": "my_tools", "use_sk": True},
]
# Tool IDs: web_tools.web_search, exec_tools.execute_code_python, my_tools.search

# Optional: per-tool runtime overrides
TOOL_RUNTIME = {
    "web_tools.web_search": "local",       # subprocess sandbox
    "exec_tools.execute_code_python": "docker",  # Docker container
}

File-producing tools have two supported paths. They can return local files declaratively:

{
  "ok": true,
  "error": null,
  "ret": {
    "artifact_type": "files",
    "files": [
      {
        "path": "outputs/report.pdf",
        "filename": "report.pdf",
        "mime": "application/pdf",
        "description": "Generated report"
      }
    ]
  }
}

Or a trusted app/catalog tool can host files itself with bundle_tool_context.host_files(...) and return the already-hosted rows. Both paths create normal hosted file metadata and emit chat.files to connected clients. Generated executor code should call a catalog tool through agent_io_tools.tool_call(...) when it needs hosted files; host_files(...) is for trusted tool code.

host_files(...) works only after the SDK has prepared the tool runtime: active ToolSubsystem, hosting service, tenant, project, user id, conversation id, turn id, conversation storage, and output directory. Normal React workflows prepare this through BaseWorkflow.build_react(...); isolated execution prepares it through bootstrap_bind_all(...). If that context is missing, the helper raises a runtime error instead of creating an unscoped artifact.

Custom domain artifact refs can also be exposed through namespace rehosters. A loaded tool or event module can register @artifact_namespace_rehoster(namespace="ext"); then react.pull(paths=["ext:..."]) resolves that opaque domain ref and returns the normal materialized fi: path for later react.read, react.rg, or generated-code use.

See custom-tools-README.md and the reference bundle’s versatile tools_descriptor.py.

MCP (Model Context Protocol) servers are declared in MCP_TOOL_SPECS and configured in the app consumer surface.

# tools_descriptor.py
MCP_TOOL_SPECS = [
    {"server_id": "web_search", "alias": "web_search", "tools": ["web_search"]},
    {"server_id": "docs", "alias": "docs", "tools": ["*"]},  # all tools
    {"server_id": "stack", "alias": "stack", "tools": ["*"]},
]
# Tool IDs: mcp.docs.some_tool, mcp.stack.some_tool
# bundles.yaml config section — how to connect
config:
  surfaces:
    as_consumer:
      mcp:
        services:
          mcpServers:
            docs:
              transport: http
              url: https://mcp.example.com
              auth:
                type: bearer
                secret: bundles.my-bundle.secrets.docs.token
            stack:
              transport: stdio
              command: npx
              args: ["mcp-remote", "mcp.stackoverflow.com"]

See mcp-README.md for all transports (stdio, http, streamable-http, sse) and auth modes.

Named-Service Tools

Named-service tools are the preferred way for agents to work with app-owned object systems. Instead of adding separate direct tools for every subsystem, a provider exposes a namespace with about/schema/search/action/upsert/delete operations and optional block policies. Consumers configure which namespaces and operations are allowed.

Provider metadata

provider.about, object.schema, search scopes, search filters, object capabilities, and default open-effect action tell the agent and UI how to handle the namespace.

Consumer policy

The tool connection declares allowed operations per namespace and strategy traits such as exploration, neutral, and exploitation.

Object projection

react.pull materializes object refs, react.read uses block production policies, and timeline rendering can call block.render for provider-owned blocks.

See Object Ecosystem & Ontologic Contracts and ReAct named-service flow.

Artifact Path Families

Artifact refs describe where data came from; they do not all mean "current editable project file." ReAct's current workspace is turn_<current>/files/.... Reports and generated deliverables belong in outputs/..., workflow state belongs in snapshots/..., and uploads or domain attachments belong in attachments/... or external/.... Use react.pull to materialize refs before local search/read by filesystem path; use react.checkout only when historical files/ refs must become editable current-turn workspace state.

PrefixResolves ToExample
fi:...files...Durable workspace/project state for a turn; historical files/ refs can be checked out into the current workspacefi:turn_123.files/app/src/main.py
fi:...outputs...Produced artifacts, reports, render sources, screenshots, PDFs, diagnostics; not editable workspace state by defaultfi:turn_123.outputs/export/report.pdf
fi:...snapshots...Story, wizard, canvas, or workflow state snapshotsfi:turn_123.snapshots/wizard/current.yaml
fi:...attachments...User uploads and hosted attachment files for a turnfi:turn_123.user.attachments/screenshot.png
fi:conv_...Cross-conversation artifact ref; materializes under conv_<conversation_id>/turn_...fi:conv_support-42.turn_123.snapshots/wizard/current.yaml
ext: or another custom namespaceOpaque bundle/domain artifact ref. Valid only when a loaded module registers a namespace rehoster; react.pull returns the materialized fi: path.ext:inventory/reorder_42/stock-snapshot.yaml
ar:Artifact from timelinear:turn_123.artifacts.summary
ks:Knowledge space (read-only, docs/src)ks:docs/architecture.md
sk:Skills space (skill instruction files)sk:public.pdf-press/SKILL.md
so:Sources from context poolso:sources_pool[1-5]
tc:Tool call blocktc:turn_123.abc.call

Skills System

Skills are reusable instruction sets that give agents specialized capabilities. A skill bundles a natural-language instruction (SKILL.md), tool references, and source references.

The skill registry is broader than a bundle's own skills/ folder. For each agent consumer, the runtime resolves core SDK skills, SDK solution skills, and bundle-local skills, then applies the bundle's AGENTS_CONFIG and the current tool catalog before showing the final list to the agent.

Built-in Platform Skills

Skill IDNamespaceDescription
url-genpublicGenerate hosted URLs for file artifacts
pdf-presspublicPDF generation and manipulation
docx-presspublicDOCX document generation
pptx-presspublicPPTX presentation generation
png-presspublicPNG image rendering from HTML/SVG
mermaidpublicMermaid diagram generation
link-evidenceinternalCitation and evidence linking
sources-sectioninternalAutomatic sources section generation

SDK solution skills, such as task-focused skills, are discovered by the same registry and filtered by the same AGENTS_CONFIG and required-tool rules. They do not need to live in the bundle's skills/ folder.

Custom Bundle Skill

# skills/product/kdcube/SKILL.md
You are an expert in our product catalog.
When asked about products, use the `product_search` tool to find relevant items.
Always include pricing and availability.
# skills/product/kdcube/tools.yaml
tools:
  - id: product_search
    role: search
    why: Search the product catalog
    required: true
# skills_descriptor.py
AGENTS_CONFIG = {
    "solver.react.v2.decision.v2.strong": {
        "enabled": ["product.kdcube", "public.pdf-press", "public.url-gen"],
        "disabled": []
    }
}

Creating a SKILL.md File

Each skill lives in its own folder under the bundle's skills/ directory. The required file is SKILL.md (or skill.yml), which contains YAML front-matter and a natural-language instruction body.

# skills/product/kdcube/SKILL.md
---
name: "Product Catalog Expert"
id: "kdcube"
namespace: "product"
description: "Search and present product information"
version: "1.0"
tags: ["catalog", "products"]
when_to_use: "When the user asks about products, pricing, or availability"
imports: []
agent_disclosure: "normal"
---

You are an expert in the product catalog.
When asked about products, use the `product_search` tool.
Always include pricing and availability in your response.

Optional companion files in the same folder:

  • compact.md — a shorter instruction variant for context-constrained agents
  • sources.yaml — sources injected into sources_pool when the skill loads (referenceable as so:sources_pool[...])
  • tools.yaml — recommended or required tools for the skill, used by planners and runtime eligibility checks

Skill Visibility Configuration

The skills_descriptor.py file controls which skills are visible to which agent consumers. It exposes two key variables:

# skills_descriptor.py
import pathlib

BUNDLE_ROOT = pathlib.Path(__file__).resolve().parent

# Points to the skills folder layout: <root>/<namespace>/<skill_id>/SKILL.md
CUSTOM_SKILLS_ROOT = BUNDLE_ROOT / "skills"

AGENTS_CONFIG = {
    # Allow-list for the ReAct decision agent
    "solver.react.v2.decision.v2.strong": {
        "enabled": ["product.kdcube"]
    },
    "solver.react.v2.decision.v2.regular": {
        "enabled": ["product.kdcube", "public.*"]
    },
    # Deny-list for a generator agent
    "answer.generator.strong": {
        "disabled": ["public.*"]
    },
}
RuleBehavior
enabled: [...]Allow-list: only listed skills are visible to that consumer
disabled: [...]Deny-list: listed skills are hidden from that consumer
WildcardsSupported in both lists ("public.*", "public.docx-*", "*")
Missing consumer entryNo descriptor filtering — registered skills may still be removed by required-tool eligibility

Runtime Skill Eligibility

AGENTS_CONFIG is only the first filter. ReAct also compares each skill's required tool references with the active tool catalog for the current agent and turn. If a skill declares a required tool that is not available, the skill is omitted from the visible catalog, SK1/SK2 short ids, imports, and react.read("sk:...") for that runtime context.

# skills/public/pdf-press/tools.yaml
tools:
  - id: rendering_tools.write_pdf
    role: render pdf
    required: true

Use required: true for subsystem skills that would confuse the agent without the matching tools. For example, PDF/DOCX/PPTX skills depend on rendering tools, and task skills depend on task tools. If those tools are not registered for a bundle, the corresponding skills disappear automatically without adding deny-list entries.

Hidden Operational Skills

Some skills are useful as imported operational guidance but should not be advertised when a user asks what the agent can do. Add agent_disclosure: hidden in the skill front matter for that case.

---
name: memory-journal
namespace: product
description: Operational guidance for durable memory tools.
agent_disclosure: hidden
imports: []
---

A hidden-disclosure skill remains loadable by exact id or import when it is otherwise eligible, but it is excluded from visible catalogs and short-id mappings. If it is explicitly loaded, the active skill block uses a redacted heading and a non-disclosure rule. This is prompt-disclosure control, not authorization; use AGENTS_CONFIG and tool gating for actual availability.

If a parent skill imports an optional subsystem skill, keep the subsystem-specific instructions inside the imported skill. The registry can skip an ineligible import, but it cannot rewrite arbitrary subsystem instructions that were duplicated in the parent skill body.

Skill Loading and Resolution

The runtime auto-detects <bundle_root>/skills as the custom skills root when CUSTOM_SKILLS_ROOT is not set. Skills are loaded into the skill registry and resolved by fully qualified id (namespace.skill_id). Consumer agents reference eligible visible skills via short-id tokens (SKx) or explicit sk:<id> references. When a skill is loaded with react.read("sk:<skill>"), its sources are merged into sources_pool and citation tokens are rewritten to match.

i
Setting CUSTOM_SKILLS_ROOT = None does not reliably disable bundle-local skills — the runtime falls back to auto-detection. To truly disable them, remove the skills/ folder or set CUSTOM_SKILLS_ROOT to a non-existent path.

Best Practices for Skill Authoring

  • Keep SKILL.md instructions focused — one skill per capability domain
  • Use when_to_use front-matter to help the agent decide when to activate the skill
  • Include tools.yaml to pair skills with the tools they need, and mark hard dependencies with required: true
  • Use AGENTS_CONFIG to restrict skill visibility rather than deleting skill files — this keeps skills available for future consumers
  • Use agent_disclosure: hidden only for loadable operational guidance that must not be advertised in self-description
  • Test visibility filtering per consumer: a skill hidden from solver.react.v2.decision.v2.strong is still visible to unfiltered consumers

Widgets, APIs, MCP & Custom UI

Apps expose a decorator-discovered interface. Widgets, REST APIs, app-served MCP endpoints, chat message handlers, named-service providers, and background job handlers are separate surfaces: widgets are UI entrypoints intended for the platform shell, APIs are programmatic operations reachable under the integrations routes, @mcp(...) exposes an MCP-native surface for external MCP clients, and @on_job receives ready work claimed by proc. An app may also define a custom main view and a dedicated message entrypoint.

💡
Think in interface surfaces: widgets, APIs, MCP endpoints, named services, ui_main, on_message, and on_job. The loader scans them from decorators and app registration hooks and exposes them as an app interface manifest.
Widget and custom UI integration diagram
Widgets and Custom UI Widget integration flow showing decorator-discovered widgets, app APIs, and app-served MCP endpoints UI Panel Loads widget GET /widgets/{alias} Integrations API /bundles/{t}/{p}/{b}/ widgets / operations / mcp Your App @ui_widget / @ui_main @api / @mcp / @on_message / @on_job Widget / Main View Rendered in UI panel UI calls /operations UI uses /operations; external MCP clients use /mcp on the same explicit bundle_id path

Buildable React/Vite Widgets

New widget apps should be source folders declared per alias under ui.widgets. The descriptor should pass the loader output destination through the standard placeholder, and the widget build config must consume it as an environment value:

ui:
  widgets:
    task_memo_webapp:
      enabled: true
      src_folder: widgets/task_memo_webapp
      build_command: npm install --no-package-lock && OUTDIR=<VI_BUILD_DEST_ABSOLUTE_PATH> npm run build
    task_webapp:
      enabled: true
      src_folder: widgets/task_webapp
      build_command: npm install --no-package-lock && OUTDIR=<VI_BUILD_DEST_ABSOLUTE_PATH> npm run build

For Vite widgets, configure output from process.env.OUTDIR and use relative assets. Do not pass the destination path as vite build <path>. ui.widgets is the build/source contract only; the runtime widget surface is still declared by a matching @ui_widget(alias="...") method. A configured folder without a decorator is not a visible widget.

export default defineConfig({
  base: './',
  build: {
    outDir: process.env.OUTDIR || 'dist',
    emptyOutDir: true,
  },
})

If runtime logs show vite build /.../.ui.build.tmp... or Vite reports UNRESOLVED_ENTRY for .ui.build.tmp.../index.html, the output directory leaked into Vite as a positional project/root argument. Fix the widget build contract or update to a platform runner that treats <VI_BUILD_DEST_ABSOLUTE_PATH> as an environment value. Do not manually copy built files into bundle storage.

App Interface Decorators

DecoratorAttaches ToPurposeKey args
@bundle_entrypoint(...)classMarks the app entrypoint for loader discoveryname, version, priority, allowed_roles, allowed_roles_config
@bundle_id(...)classDeclares the canonical internal app id from codeid
@ui_widget(...)methodDeclares a UI widget discoverable under the widgets endpointsicon, alias, user_types, user_types_config, roles, roles_config
@api(...)methodDeclares an app API method under /operations/{operation} or /public/{operation}method=POST|GET, alias, route, user_types, user_types_config, roles, roles_config, public_auth
@mcp(...)methodDeclares an app-served MCP endpoint under /mcp/{alias} or /public/mcp/{alias}alias, route, transport
@ui_mainmethodMarks the app's main UI entrypoint returned in the interface manifestno args
@on_messagemethodMarks the message-entry method the proc/web layer can route to for live message handlingno args
@on_jobmethodMarks the async ready-job handler called by proc after claiming a background job stream itemno args; method receives the job envelope and dispatch kwargs
@public_content(...)methodDeclares a public discoverable-content alias: the platform serves crawlable pages, JSON-LD, and a per-alias sitemap from the app's content registryalias, schema_type

Bundle identity can come from the registry entry or be declared explicitly with @bundle_id("my.bundle@version"). The loader uses the discovered bundle interface decorators as the HTTP, UI, message, and job contract. Use user_types=(...) for inferred platform user types such as anonymous, registered, paid, or privileged, and use roles=(...) for raw external auth roles such as kdcube:role:super-admin. user_types are threshold-based, not exact-match: the order is anonymous < registered < paid < privileged, so user_types=("registered",) means registered-or-higher, and user_types=("paid",) means paid-or-higher. If both user_types and roles are provided, both checks must pass. Add user_types_config and roles_config to APIs/widgets when Bundle Admin should override those defaults from bundle props. Add allowed_roles_config to @bundle_entrypoint when bundle-level listing visibility should be configurable. Resource enabled state is no longer a decorator argument for APIs or MCP; use bundle props/Admin resource overrides. That threshold rule applies to @api and @ui_widget when proc owns the route auth contract. @api(route="public") now has three explicit auth shapes: public_auth="none", built-in proc-side header_secret, and bundle-owned public_auth="bundle". @mcp is different again: proc does not enforce user_types, roles, or public_auth there; the bundle MCP app owns request authentication and authorization. @on_job is also different: it is not URL-addressable, must be async, and receives jobs already admitted to the background job stream. If an entrypoint inherits SDK mixins, the decorated @on_job method should call await super().handle_job(**kwargs) before handling custom work_kind values.

Example: Declare widget, API, MCP, and UI entrypoints

from fastapi import HTTPException, Request
from kdcube_ai_app.apps.chat.sdk.config import get_secret_async
from kdcube_ai_app.infra.plugin.bundle_loader import bundle_entrypoint, bundle_id, api, mcp, ui_widget, ui_main, on_message

@bundle_entrypoint(name="my-bundle", version="1.0.0", priority=100)
@bundle_id("my.bundle@1.0.0")
class MyBundle(BaseEntrypointWithEconomics):

    @ui_widget(icon={"tailwind": "heroicons-outline:swatch"}, alias="dashboard", user_types=("registered", "privileged"))
    def dashboard_widget(self, **kwargs):
        return ["<div id='root'></div>"]

    @api(alias="refresh_dashboard", method="POST", user_types=("registered", "privileged"))
    def refresh_dashboard(self, **kwargs):
        return {"ok": True}

    @api(alias="telegram_webhook", method="POST", route="public", public_auth="bundle")
    async def telegram_webhook(self, request: Request, **kwargs):
        header_name = self.bundle_prop("telegram.webhook.auth.header_name", "X-Telegram-Bot-Api-Secret-Token")
        expected_token = await get_secret_async("b:telegram.webhook.auth.shared_token")
        if request.headers.get(header_name) != expected_token:
            raise HTTPException(status_code=401, detail=f"Missing or invalid {header_name}")
        return {"ok": True}

    @mcp(alias="tools", route="operations", transport="streamable-http")
    async def tools_mcp(self, request: Request):
        from mcp.server.fastmcp import FastMCP

        header_name = self.bundle_prop("mcp.inbound.auth.header_name", "X-Bundle-MCP-Token")
        expected_token = await get_secret_async("b:mcp.inbound.auth.shared_token")
        if request.headers.get(header_name) != expected_token:
            raise HTTPException(status_code=401, detail=f"Missing or invalid {header_name}")

        server = FastMCP("my-bundle")

        @server.tool()
        def ping() -> str:
            return "pong"

        return server

    @ui_main
    def main_view(self, **kwargs):
        return ["<div id='app'></div>"]

    @on_message
    async def run_message(self, **kwargs):
        return await self.run(**kwargs)
i
Bundle-owned public API hook: use @api(..., route="public", public_auth="bundle") when the bundle, not proc, must verify the inbound request. Put the client-facing header name in bundles.yaml -> items[].config.telegram.webhook.auth.header_name, put the verification token in bundles.secrets.yaml -> items[].secrets.telegram.webhook.auth.shared_token, accept request: Request in the method, and validate request.headers[...] there.
i
Bundle-owned MCP auth example: here the bundle defines the client contract through bundle props and bundle secrets. Put the header name in bundles.yaml -> items[].config.mcp.inbound.auth.header_name, put the verification token in bundles.secrets.yaml -> items[].secrets.mcp.inbound.auth.shared_token, then validate request.headers[...] in the MCP provider before returning the FastMCP app. Proc does not verify this token for MCP.

Integrations HTTP Surface

EndpointPurposeNotes
GET /api/integrations/bundles/{tenant}/{project}/{bundle_id}Return the bundle interface manifestIncludes ui_widgets, api_endpoints, mcp_endpoints, ui_main, on_message
GET /api/integrations/bundles/{tenant}/{project}/{bundle_id}/widgetsList visible widgets for the current userReturns alias, icon, user_types, roles
GET /api/integrations/bundles/{tenant}/{project}/{bundle_id}/widgets/{widget}Render or fetch one widgetResolved via @ui_widget
POST /api/integrations/bundles/{tenant}/{project}/{bundle_id}/operations/{operation}Call a bundle API operationResolved via @api; preferred explicit form
GET /api/integrations/bundles/{tenant}/{project}/{bundle_id}/operations/{operation}GET variant for idempotent operations and static-like API readsResolved via the same interface manifest
POST /api/integrations/bundles/{tenant}/{project}/{bundle_id}/public/{operation}Call a public bundle API operationResolved only via @api(route="public", ...); current public methods must declare public_auth; public_auth="bundle" forwards the request for bundle-owned verification
GET /api/integrations/bundles/{tenant}/{project}/{bundle_id}/public/{operation}GET variant for public idempotent bundle APIsSupports the same route-scoped manifest contract
GET|POST /api/integrations/bundles/{tenant}/{project}/{bundle_id}/mcp/{alias}Call a bundle-served MCP endpointResolved only via @mcp(route="operations", ...); current transport is streamable-http; proc forwards headers/body and the bundle authenticates the request itself if it wants auth
GET|POST /api/integrations/bundles/{tenant}/{project}/{bundle_id}/public/mcp/{alias}Call a public bundle-served MCP endpointResolved only via @mcp(route="public", ...); this selects the public URL family, but proc still does not apply public_auth for MCP
POST /api/integrations/bundles/{tenant}/{project}/operations/{operation}Default-bundle shortcutCompatibility path when bundle_id is omitted
GET /api/integrations/static/{tenant}/{project}/{bundle_id}/{path}Serve bundle-scoped static assetsUseful for bundle-shipped frontend assets and media
i
Public bundle APIs are now a real platform surface. Use route="public" only for intentionally public endpoints, and declare public_auth explicitly. Current modes are "none" for intentionally open routes, built-in proc-side header_secret, and bundle-owned "bundle" when the bundle should authenticate the hook itself.
i
MCP auth model: @mcp(...) is bundle-authenticated, not proc-authenticated. Proc forwards the raw HTTP request into the returned FastMCP or ASGI subapp. Header names, cookies, bearer tokens, HMAC signatures, or custom JWT verification are all bundle-defined concerns for MCP. AUTH.ID_TOKEN_HEADER_NAME is not the MCP auth contract.

How to configure and call a bundle-authenticated public API hook

Use the same explicit split as MCP: the bundle defines the non-secret client contract in bundle props, stores verification material in bundle secrets, and checks the incoming request itself.

Server-side configuration

# bundles.yaml
bundles:
  version: "1"
  items:
    - id: "partner.tools@1-0"
      config:
        telegram:
          webhook:
            auth:
              header_name: "X-Telegram-Bot-Api-Secret-Token"
# bundles.secrets.yaml
bundles:
  version: "1"
  items:
    - id: "partner.tools@1-0"
      secrets:
        telegram:
          webhook:
            auth:
              shared_token: "replace-in-real-deployment"

What to share with the hook caller

  • the public route: /api/integrations/bundles/{tenant}/{project}/{bundle_id}/public/telegram_webhook
  • the header name from bundle props, for example X-Telegram-Bot-Api-Secret-Token
  • the shared token provisioned in bundle secrets

Bundle-authenticated public API client call

curl -X POST \
  "http://localhost:5173/api/integrations/bundles/<tenant>/<project>/<bundle_id>/public/telegram_webhook" \
  -H "X-Telegram-Bot-Api-Secret-Token: <shared-token>" \
  -H "Content-Type: application/json" \
  -d '{"update_id":1}'

How to configure and call a bundle MCP endpoint

Use one explicit contract. The bundle defines the non-secret client contract in bundle props, stores verification material in bundle secrets, and checks the incoming headers itself before returning the FastMCP app.

Server-side configuration

# bundles.yaml
bundles:
  version: "1"
  items:
    - id: "versatile@2026-03-31-13-36"
      config:
        mcp:
          preferences:
            auth:
              header_name: "X-Versatile-Preferences-MCP-Token"

# bundles.secrets.yaml
bundles:
  version: "1"
  items:
    - id: "versatile@2026-03-31-13-36"
      secrets:
        mcp:
          preferences:
            auth:
              shared_token: "<rotate-me>"

Bundle code

@mcp(alias="preferences_tools", route="operations", transport="streamable-http")
async def preferences_tools_mcp(self, request: Request, **kwargs):
    header_name = self.bundle_prop(
        "mcp.preferences.auth.header_name",
        "X-Versatile-Preferences-MCP-Token",
    )
    expected_token = await get_secret_async("b:mcp.preferences.auth.shared_token")
    if request.headers.get(header_name) != expected_token:
        raise HTTPException(status_code=401, detail=f"Missing or invalid {header_name}")
    return build_preferences_mcp_app(...)

What to share with the MCP client

  • the MCP route: /api/integrations/bundles/{tenant}/{project}/{bundle_id}/mcp/{alias}
  • the header name from bundle props, for example X-Versatile-Preferences-MCP-Token
  • the token provisioned in bundle secrets

Authenticated MCP client call

curl -X POST \
  "http://localhost:5173/api/integrations/bundles/demo-tenant/demo-project/versatile@2026-03-31-13-36/mcp/preferences_tools" \
  -H "X-Versatile-Preferences-MCP-Token: <shared-token>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":"1","method":"tools/list"}'

Public MCP client call

curl -X POST \
  "http://localhost:5173/api/integrations/bundles/demo-tenant/demo-project/my.bundle/public/mcp/public_tools" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":"1","method":"tools/list"}'
i
Route semantics: for @mcp(...), route="operations" and route="public" select the URL family only. They do not choose the auth verifier. If a bundle wants auth, the bundle verifies it. If a bundle wants a truly public MCP endpoint, it simply does not enforce auth in its MCP provider.

Interface Manifest Discovery

Decorators on the bundle class are the source of truth for the bundle's HTTP, UI, message, and job contract. At load time the loader scans the entrypoint class for @api, @mcp, @ui_widget, @ui_main, @on_message, and @on_job decorators and builds a typed BundleInterfaceManifest:

# Internal manifest shape (built automatically by the loader)
BundleInterfaceManifest(
    bundle_id="versatile@2026-03-31-13-36",
    ui_widgets=(UIWidgetSpec(alias="preferences", icon={...}, user_types=(...), roles=(...)),),
    api_endpoints=(APIEndpointSpec(alias="preferences_exec_report", http_method="POST", route="operations", user_types=(...), roles=(...)),),
    mcp_endpoints=(MCPEndpointSpec(alias="tools", route="operations", transport="streamable-http"),),
    ui_main=UIMainSpec(method_name="main_ui"),
    on_message=OnMessageSpec(method_name="run"),
)

The manifest is returned by GET /api/integrations/bundles/{tenant}/{project}/{bundle_id} and drives all route resolution. Only decorated methods are remotely callable — there is no same-name fallback for undecorated methods.

MCP Tools vs Bundle-Served MCP

These are different surfaces and they solve opposite directions of integration.

Use caseHow you declare itWhat it doesWhere it is live
Use external MCP servers as tools inside your agentMCP_TOOL_SPECS in tools_descriptor.py plus app props config.surfaces.as_consumer.mcp.servicesAdds outbound tool IDs such as mcp.docs.search to the bundle tool catalogInside agent/tool execution rounds
Expose an MCP-native surface from your bundle@mcp(...) on an entrypoint methodMounts a FastMCP or MCP-ready ASGI app through proc under /mcp/{alias} or /public/mcp/{alias}At the integrations HTTP layer, callable by external MCP clients

Widget UI Model

Widget methods (@ui_widget) declare bundle widget UI surfaces. Source-folder widgets are built and served by KDCube; legacy widgets may return small HTML fragments directly. The widget fetch endpoint resolves the alias, checks user_types and raw roles visibility, and invokes the method. user_types use the same threshold rule here: anonymous < registered < paid < privileged. If the same widget must also be callable through the operations route (for legacy clients), decorate it with both @ui_widget and @api:

@api(alias="task-board", route="operations", user_types=("registered",))
@ui_widget(alias="task-board", icon={"tailwind": "heroicons-outline:check-badge"}, user_types=("registered",))
def task_board(self, **kwargs):
    return ["<div id='root'></div>"]

Widget and main UI browser code should follow the same runtime config bridge as the working platform widgets. First try the platform config endpoint GET /api/cp-frontend-config; if it returns usable config, do not wait for the parent frame. If the endpoint is unavailable or the host app owns the config, fall back to postMessage: send CONFIG_REQUEST to the parent frame and accept both CONN_RESPONSE and CONFIG_RESPONSE. Build bundle operation URLs from baseUrl, defaultTenant, defaultProject, and defaultAppBundleId. Do not hardcode tenant, project, or bundle id from the source folder. Widget load should stay read-only by default; use an explicit in-widget action when the widget needs a syncing operation. For platform widgets, the preferred POST /operations/{alias} body shape is { "data": { ... } }; proc also accepts a raw JSON object body and treats it as data for webhook-style integrations. Clients should unwrap the returned {alias} field from the integrations response envelope.

When embedded in an iframe outside the platform shell, bundle UIs should also report their own size to the parent after mount and after layout changes: window.parent.postMessage({ type: "kdcube-resize", height: document.documentElement.scrollHeight, width: document.documentElement.scrollWidth }, "*"). The parent must still validate the message origin before applying the resize.

Use the SDK reference page for the exact widget/runtime config pattern and example: bundle-widget-integration-README.md.

Static File Serving for Custom UIs

Bundles that ship a custom frontend (typically a Vite/React SPA in ui/main/ for the main view or ui/widgets/<alias>/ for widgets) have their built assets served from a dedicated static route:

GET /api/integrations/static/{tenant}/{project}/{bundle_id}/{path}

The endpoint serves files from the bundle's stable bundle_storage_root()/ui/ subtree. Missing paths fall back to index.html for client-side routing. A <base> tag is injected into index.html so relative assets resolve correctly.

i
The UI build runs during on_bundle_load() via _ensure_ui_build(). It resolves src_folder, runs build_command, and stores output under <bundle_storage_root>/ui/. A .ui.signature file skips rebuilds when nothing changed. Build-on-first-request is also supported if the UI was not yet built in the current process.

Public Discoverable Content (SEO Pages & Sitemap)

Widgets and custom UIs are client-rendered — a crawler that fetches them sees an SPA shell. When an app publishes content that should be discoverable (articles, docs, catalog entries), it declares a public content alias with @public_content(alias=..., schema_type=...) and enables it in config. The app publishes, updates, and retracts items through the SDK registry (kdcube_ai_app.apps.chat.sdk.pub); the platform then serves the full discoverability layer with no further app code — crawlable HTML pages with real title/meta/body (verifiable with curl, no JS), rel=canonical + Open Graph/Twitter metadata, JSON-LD (declared @type plus BreadcrumbList), a per-alias sitemap.xml with accurate lastmod, and 410 Gone after retraction:

GET /api/integrations/bundles/{tenant}/{project}/{bundle_id}/public/__content__
      → machine-readable sitemap descriptor list (host federation)
GET .../public/__content__/{alias}/sitemap.xml
      → the per-alias sitemap (published items only)
GET .../public/__content__/{alias}/{slug...}
      → crawlable item page (200) · 410 retracted · 404 unknown

Exposure is explicit: the alias must be enabled in the app's public_content.<alias> config block, and each item carries a published/retracted state — there are no per-user audience selectors on this surface. canonical_base in the same block decouples the canonical URL from the serving route: the operator maps a clean prefix at the CDN/proxy (a rewrite, never a redirect — a URL answering 3xx cannot be a canonical), and page canonicals, JSON-LD, and sitemap entries all use it. The widget URL stays a widget shell; the crawlable page is a separate platform-rendered artifact.

i
Content-provider apps should declare singleton: true: every crawler request resolves the app instance for the declaration/config gate (no app operation code runs on the serving path), so a non-singleton app would construct a fresh entrypoint per crawled page.

Reference docs: public-content-provider-README.md (the app-surface contract), public-content-solution-README.md (the cdn-pub solution: registry tiers, serving, split-origin CDN deployment), and the step-by-step publish-discoverable-content-README.md recipe.

Main View Configuration

To define a custom main view, declare ui.main_view in your bundle's configuration property and mark a method with @ui_main:

@property
def configuration(self):
    return {
        "ui": {
            "main_view": {
                "src_folder": "ui/main",
                "build_command": "npm install && OUTDIR=<VI_BUILD_DEST_ABSOLUTE_PATH> npm run build",
            }
        }
    }

The built SPA communicates with the backend through the bundle operations endpoint and receives runtime config from GET /api/cp-frontend-config with parent-frame postMessage as fallback. The UI is a normal platform client — if it needs bundle-originated events targeting one exact connected peer, it must propagate the connected peer id on REST requests per the client communication contract. Embedded pages should emit the same kdcube-resize height/width message used by widgets.

Memory Widget and Reconciliation

Bundles that inherit BaseEntrypointWithMemory or BaseEntrypointWithEconomicsAndMemory get the SDK memory widget operations: list/create/update/delete, snapshots, snapshot export/restore preview, and reconciliation jobs. Enable the widget through bundle props/config under memory.enabled and memory.widget.enabled, and configure the built widget source under ui.widgets.memories.

Manual reconciliation is submitted through memories_widget_reconcile_run. The request may include agent_type with one of lite, regular, or strong. At execution time that selector maps the logical memory.reconciler role to memory.reconciler.lite, memory.reconciler.regular, or memory.reconciler.strong for that job only. Configure those role models in the bundle's role_models props.

config:
  role_models:
    memory.reconciler.lite:
      provider: anthropic
      model: claude-haiku-4-5-20251001
    memory.reconciler.regular:
      provider: anthropic
      model: claude-sonnet-4-6
    memory.reconciler.strong:
      provider: anthropic
      model: claude-opus-4-1

If the bundle needs extra per-job controls, send them in reconciliation_context and override on_memory_reconciliation_request(request=...) to validate or augment them before the job is stored. The SDK persists this context with the job and rebinds it under bundle_call_context.memory.reconciliation.context when the background reconciler runs.

async def on_memory_reconciliation_request(self, *, request: dict) -> dict | None:
    context = dict(request.get("reconciliation_context") or {})
    context.setdefault("policy", "strict")
    return {
        "agent_type": request.get("agent_type") or "regular",
        "reconciliation_context": context,
    }

Registry vs. Interface Discovery

GET /api/admin/integrations/bundles returns the configured bundle registry entries — id, repo, ref, module, path, version, and related deployment metadata. The richer interface discovery surface lives on the per-bundle integrations endpoint above, where the runtime scans decorators and returns the current manifest.

Common Bundle Operations

OperationDescription
ai_bundlesBundle admin dashboard (props editor, status) — all bundles inherit this
control_planeControl-plane dashboard — via the platform base entrypoint
economic_usage / opexEconomics and operational usage views — via BaseEntrypointWithEconomics
memories and memories_widget_*Memory UI plus memory operations — via BaseEntrypointWithMemory
suggestionsSuggested prompts for new conversations
inventory-reorder-apiExample structured API for an inventory widget: list low-stock items, create reorder requests, schedule replenishment scans, trigger immediate execution

Deploying Your Bundle

Option A: With the KDCube Platform

  1. 1

    Push your bundle to Git

    git push origin v1.0.0
  2. 2

    Add to bundles.yaml

    - id: "my-bundle@1-0"
      repo: "git@github.com:org/my-bundle.git"
      ref: "v1.0.0"
      module: "my_bundle.entrypoint"
  3. 3

    Initialize secrets and apply

    kdcube init --tenant <tenant> --project <project> \
      --descriptors-location /path/to/descriptors \
      --set-secret services.openai.api_key "sk-..." \
      --set-secret services.anthropic.api_key "sk-ant-..." \
      --set-secret services.brave.api_key "..." \
      --set-secret services.git.http_token "github_pat_..." \
      --set-secret git.http_token "github_pat_..."
    
    kdcube info --tenant <tenant> --project <project>

    The CLI writes these values into the staged active config/secrets.yaml, resolves descriptor placeholders such as tenant/project/domain where the descriptor set supports it, and then the runtime reads that staged copy.

  4. 4

    Set as default bundle via the Admin Dashboard

    Open the AI Bundles Dashboard (/api/integrations/bundles/{tenant}/{project}/{bundle_id}/operations/ai_bundles). Your registered bundle appears in the list. Set it as the default_bundle_id for the tenant/project. The change is applied immediately via Redis — no restart needed.

Option B: Standalone (Without Platform)

Bundles can run outside the platform. The SDK is a plain Python package — build your own FastAPI app that imports and invokes it directly. Or build a custom Docker image that runs just your bundle with its own server. The platform's value is in hosting, auth, SSE, storage, economics, and UI — none of that is required for the core agent logic.

Bundle Git Auth

Modebundles.yamlSecret
SSH keygit@github.com:org/repo.gitSSH key mounted in container
HTTPS tokenhttps://github.com/org/repo.gitservices.git.http_token / git.http_token in secrets.yaml

Fast Local Bundle Prototyping

KDCube supports a descriptor-driven local bundle iteration loop optimized for fast changes. The intended loop is: initialize once from descriptors, edit the bundle locally under the mounted bundles root, then reload just that bundle without reinstalling the platform.

# one-time initialization
kdcube init --tenant <tenant> --project <project> \
  --descriptors-location /path/to/descriptors \
  --set-secret services.openai.api_key "sk-..." \
  --set-secret services.anthropic.api_key "sk-ant-..."

kdcube start --tenant <tenant> --project <project>

# then during development
# edit files under assembly.paths.host_bundles_path
kdcube bundle reload my.bundle@1.0.0 --tenant <tenant> --project <project>

# rebuild platform images later (descriptors preserved)
kdcube refresh --tenant <tenant> --project <project> --build
kdcube refresh --tenant <tenant> --project <project> --path /path/to/kdcube-ai-app --build
kdcube refresh --tenant <tenant> --project <project> --release <ref> --build

# reapply seed bundle descriptors only
kdcube bundle config apply --tenant <tenant> --project <project> \
  --descriptors-location /path/to/descriptors --dry-run
kdcube bundle config apply --tenant <tenant> --project <project> \
  --descriptors-location /path/to/descriptors --reload

The important path rule is that the host bundle lives under the host bundles root declared in assembly.yaml, while bundles.yaml points to the container-visible path:

# assembly.yaml
paths:
  host_bundles_path: "/Users/you/dev/bundles"

# bundles.yaml
bundles:
  items:
    - id: "my.bundle@1.0.0"
      path: "/bundles/my.bundle"
      module: "entrypoint"

Use this flow for local code changes, descriptor-backed bundle config edits, and quick widget/API iteration. init stages the active descriptor set under the concrete runtime workdir. bundle reload replays that active staged descriptor, clears bundle caches, and makes the next request load the updated bundle code. bundle config apply stages only bundles.yaml and optional bundles.secrets.yaml; it does not rebuild images or restart Docker. Before replacing live runtime bundle descriptors with older seed files, use kdcube export --out-dir ... to write reviewable bundles.yaml and bundles.secrets.yaml snapshots.

Expose a CLI-Started Local Runtime with Ngrok

For Telegram webhooks, Telegram Mini Apps, Cognito callbacks, or any other external callback into a local KDCube runtime, run KDCube with the CLI first and expose the CLI web proxy port with ngrok. Do not expose proc separately.

kdcube start --tenant <tenant> --project <project>

# use the port printed by kdcube start, commonly 5173 for local descriptors
ngrok http --host-header=rewrite 5173

The CLI-started Docker Compose stack already includes the KDCube web proxy, which routes /api/integrations/* to proc, /api/* and /sse/* to ingress, and browser routes to the frontend. After ngrok gives you https://<ngrok-domain>, use that single origin in CORS/Cognito callback settings and in bundle public integration URLs such as Telegram webhooks. If assembly.yaml changes, restart with kdcube refresh --tenant T --project P; use --path, --latest, --upstream, or --release <ref> only when platform source should change. If bundle config/secrets change, use kdcube bundle reload <bundle_id> or reapply seed bundle descriptors with kdcube bundle config apply --descriptors-location ... --reload.

Reference recipe: ngrok-README.md.

Bundle-Scoped @venv(...) Helpers

Bundles can mark dependency-heavy helper functions with @venv(...). The platform creates a cached per-bundle subprocess venv, overlays the runtime packages already present in proc, then installs the bundle's requirements.txt on top. The venv is rebuilt only when the requirements file changes.

from kdcube_ai_app.infra.plugin.bundle_loader import api, venv

@venv(requirements="requirements.txt", timeout_seconds=120)
def parse_external_document(payload: dict) -> dict:
    ...

class MyBundle(BaseEntrypoint):
    @api(alias="ingest_document", route="operations")
    async def ingest_document(self, **kwargs):
        result = parse_external_document(kwargs)
        await self.comm.event(
            type="bundle.ingest.completed",
            step="ingest_document",
            status="completed",
            title="Document processed",
            data={"pages": result.get("pages", 0)},
        )
        return result

Boundary rule: @venv(...) is for plain serialized inputs and outputs only. Keep communicator use, request context, DB pools, Redis clients, and other live proc-bound runtime objects outside the venv helper. If only requirements.txt changes, the next helper call rebuilds the cached venv lazily. If Python source changes, use the normal bundle reload path.

Bundle-Scheduled @cron(...) Jobs

Bundles can now declare proc-owned scheduled jobs directly in bundle code with @cron(...). This is the new native way to bind periodic custom bundle logic to the platform lifecycle instead of wiring an external scheduler for every bundle-specific routine.

from kdcube_ai_app.infra.plugin.bundle_loader import cron

class MyBundle(BaseEntrypoint):
    @cron(
        alias="rebuild_index",
        expr_config="routines.rebuild.cron",
        cron_expression="0 */6 * * *",
        span="instance",
    )
    async def rebuild_index(self):
        ...

The shipped contract is: @cron(alias=..., cron_expression=..., expr_config=..., span=...). Use cron_expression for an inline schedule or expr_config for a dot-separated config path such as apps.app1.routines.cron. If both are provided, expr_config wins. If the resolved config value is missing, blank, or disable, the job is inert and nothing is scheduled. span controls exclusivity across process, instance, or system.

A practical example is a scheduled inventory routine that materializes the next reorder queue and publishes an operational event for the UI:

class InventoryBundle(BaseEntrypoint):
    @cron(alias="refresh-reorder-queue", cron_expression="0 6 * * *", span="system")
    async def refresh_reorder_queue(self):
        items = await self.rebuild_reorder_queue()
        await self.comm.event(
            type="bundle.inventory.reorder_queue_refreshed",
            step="refresh_reorder_queue",
            status="completed",
            title="Reorder queue refreshed",
            data={"items": len(items)},
        )

Background Job Stream and @on_job

For work that should not run inside a scheduler tick or widget request, enqueue a background job and handle it with @on_job. The producer creates the durable bundle-owned record first, then writes a ready-work envelope to Redis Streams. Proc claims jobs fairly across workers, builds a bundle runtime context, and invokes the bundle's async @on_job handler.

Background job lifecycle from cron or API producer to bundle @on_job handler
Background job lifecycle A producer creates a domain record, enqueues a Redis Stream job, proc claims it, and bundle @on_job executes it. Producer @cron / API create Domain Record execution / status enqueue Redis Stream ready job envelope claim Proc fair worker invoke @on_job async
from kdcube_ai_app.infra.plugin.bundle_loader import cron, on_job

class TaskBundle(BaseEntrypoint):
    @cron(alias="due-scan", cron_expression="*/5 * * * *", span="system")
    async def due_scan(self):
        await self.tasks.enqueue_due_jobs()

    @on_job
    async def on_job(self, job: dict, **kwargs):
        del kwargs
        if job.get("work_kind") == "task.execution.due":
            return await self.tasks.run_execution(job["payload"]["execution_id"])
        return {"ok": False, "error": {"code": "unsupported_job"}}

@on_job is not a public route and not a widget operation. It should validate work_kind, load durable ids from payload, and update bundle-owned execution/result state. Until proc acknowledges the stream message, retry is possible.

Example Bundles

Bundle IDWhat It ShowsKey Features
versatile@2026-03-31-13-36Reference bundle for SDK and integrations docsReAct agent, bundle-local tools, MCP tools, widget decorators, integrations operations, Telegram hook/webapp and attachment patterns, custom main UI under ui/main/, widget UI under ui/widgets/, and economics-aware entrypoint patterns
versatile — widgets, Telegram, operations, and custom main-view pattern

The versatile sample shows how one bundle can combine a standard ReAct conversation workflow with decorated widget endpoints, bundle APIs, Telegram webhook/Mini App surfaces, attachment handling, and a custom SPA main view. It is the best current public reference for how entrypoint methods, tools_descriptor.py, ui/main/, and ui/widgets/ fit together.

View entrypoint.py on GitHub →

Documentation Reference

Coming Soon & Current Status

Here's the current state of platform capabilities and what's next on the roadmap:

FeatureStatusNotes
Bundle-declared widgets Available Bundles now mark their UI widgets explicitly with @ui_widget. The platform exposes that widget manifest via GET /api/integrations/bundles/{tenant}/{project}/{bundle_id} and /widgets, so frontends can render bundle-defined widget launchers directly from discovered metadata instead of hardcoded platform lists.
Custom main view Available Bundles can override the default chat surface with a custom main UI via the reserved ui.main_view config and a discovered @ui_main entrypoint. This is not limited to static dashboards: a bundle main view can be a full SPA using REST, SSE streaming, widgets, downloads, and any richer client behavior the bundle wants to implement.
Static asset serving from bundle Available Bundle-scoped static files can now be served directly via GET /api/integrations/static/{tenant}/{project}/{bundle_id}/{path}. GET operations are also available for idempotent bundle APIs under /operations/{operation}.
Anonymous public bundle APIs Available Bundle APIs can now be exposed under /public/{operation} through @api(..., route="public"). Public methods must declare public_auth; current built-in modes are explicit open access ("none") and shared-secret header verification for webhook providers such as Telegram.
Bundle-served MCP endpoints Available Bundles can expose MCP-native HTTP endpoints through @mcp(...). Proc routes them under /mcp/{alias} or /public/mcp/{alias} and forwards the raw request into the returned FastMCP or ASGI subapp, but the bundle owns MCP authentication and authorization.
Bundle endpoints RBAC Available Bundle endpoint visibility and access are already governed by decorator metadata where proc owns the route contract. Bundle listing honors bundle-level allowed_roles, while per-method user_types and raw roles on @api and @ui_widget control which operations and widgets are visible and callable for a given user. user_types use threshold semantics with the order anonymous < registered < paid < privileged. If both are present, both checks must pass. Public API endpoints additionally require explicit public_auth. @mcp does not use proc-side user_types, roles, or public_auth.
Admin bundle interface enrichment Available /api/admin/integrations/bundles now returns registry metadata enriched with scanned bundle manifest data such as widgets, apis, on_message, on_job, and scheduled jobs. This gives admins a control-plane view of what a bundle exposes without opening the bundle code.
Bundle marketplace Soon Browse and install community bundles from a registry.
Local bundle dev fast prototyping Available Descriptor-driven local development now has a first-class fast path. Point assembly.yaml at paths.host_bundles_path, define the bundle in bundles.yaml with a container path under /bundles, initialize once with kdcube init --descriptors-location ... --workdir ..., then iterate with kdcube bundle reload <bundle_id> --workdir <runtime-workdir>. The live local deployment-scoped bundle authority is the staged runtime copy under workdir/config/, not the external source directory. See how-to-configure-and-run-bundle-README.md.
Live bundle reload Available Bundle code is loaded per-process and cached as a singleton, but local development no longer depends on restarting proc. Config overrides propagate immediately via Redis, and descriptor-backed local changes can be picked up with kdcube bundle reload <bundle_id> --workdir <runtime-workdir>, which replays the active staged descriptor and clears the bundle cache for the next request. Bundle Admin updates to deployment-scoped bundle props and bundle secrets persist into the live runtime authority first, then reload applies that authority.
on_props_changed(...) lifecycle hook Available BaseEntrypoint exposes on_props_changed(...) for reconciling long-lived side effects after effective bundle props changed. It runs after prop refresh when effective props actually changed, and already-loaded singleton bundles in the current worker receive it on live bundles.props.update events.
Bundle versioning Available Update the ref (branch/tag/commit) in bundles.yaml or via the Admin API and the change applies immediately to new requests. Redis is the runtime cache, not the long-term authority. In the recommended deployment model, deployment-scoped bundle descriptors and non-secret bundle props persist in writable bundles.yaml, and bundle export reconstructs that file. See bundle-runtime-configuration-and-secrets-README.md.
Deployment-scoped bundle props and secrets Available Bundles read deployment-scoped non-secret config with self.bundle_prop(...) and deployment-scoped bundle secrets with await get_secret_async("b:..."). Deployment-scoped bundle props are exportable and belong in bundles.yaml; deployment-scoped bundle secrets belong in bundles.secrets.yaml or the configured secrets provider. See bundle-runtime-configuration-and-secrets-README.md.
User-scoped bundle state Available Bundles can persist per-user non-secret state with get_user_prop(...) / set_user_prop(...) and per-user secrets with await get_user_secret_async(...) / await set_user_secret_async(...). User-scoped state is operational runtime data. It never belongs in bundles.yaml, bundles.secrets.yaml, or kdcube export. See bundle-runtime-configuration-and-secrets-README.md.
Runtime feature gating and configurable visibility Available Bundles can gate the whole bundle through the canonical enabled.bundle prop, expose Admin-managed resource enabled flags, and make bundle/API/widget visibility configurable with allowed_roles_config, user_types_config, and roles_config. Do not use removed resource-level enabled_config decorator arguments. See bundle-platform-integration-README.md.
Bundle-scoped @venv helpers Available Bundles can run selected dependency-heavy helper functions in a cached per-bundle subprocess venv. The venv inherits the platform runtime packages, installs the bundle's requirements.txt on top, and rebuilds only when the requirements hash changes. This is intended for leaf helper work, not for communicator, DB, Redis, or other live proc-bound runtime objects.
Bundle-local Node / TS backend sidecar Available Bundles can keep the public KDCube app surface in Python and run selected backend logic as a bundle-local Node or TypeScript sidecar. This is the supported wrapping pattern for existing Node backends. Startup-config drift triggers lazy restart on next use; live config can be pushed lazily through POST /__kdcube/reconfigure.
Bundle-scheduled @cron jobs Available Bundles can now declare scheduled jobs directly in bundle code with @cron(alias=..., cron_expression=..., expr_config=..., span=...). The scheduler resolves expr_config first, falls back to inline cron_expression only when no config path is declared, and supports process, instance, and system exclusivity spans.
Bundle background @on_job handlers Available Bundles can declare one async @on_job handler for ready work claimed by proc from the Redis background job stream. This is the execution side of queued background work: @cron or a run-now operation can enqueue, while @on_job validates work_kind, reads ids from payload, and updates bundle-owned result state.
Git-backed React workspace Available React now supports a git workspace backend with sparse current-turn repos, lineage-isolated history, explicit materialization through react.pull(...), and active workspace construction through react.checkout(mode="replace"|"overlay", ...). The agent-facing contract is the same in custom and git modes: files/ is editable workspace state, outputs/ and snapshots/ are artifacts, cross-conversation refs use fi:conv_..., and custom namespaces such as ext: must be rehosted by react.pull before normal file tools can use them.
Bundle event sources and artifact rehosters Available Tools and authored UI events can be declared as event sources with phase-bound policies. block_production policies let tools and external events produce the timeline blocks/artifact rows they own, while registered namespace rehosters let react.pull materialize refs such as ext:... into ordinary fi: workspace paths.
Per-bundle React runtime instructions Available Bundles can append deploy-scoped prompt guidance to the React decision agent through react.additional_instructions. This is useful for bundle-specific style, output-format, or domain guardrails without forking the base system prompt.
Claude Code agent integration Available Bundles can now integrate the Claude Code agent as an accountable LLM runtime, including per-turn model, token, cache, and spend reporting plus optional git-backed session continuity. The kdcube.copilot reference bundle shows the pattern end to end for admin-facing workflows.
Interactive steer / followup execution Available Busy-turn followup and steer are implemented end to end for React. They enter the shared conversation event lane, the live React runtime folds them into the current timeline when it owns the turn, followup continues the current turn, and steer redirects at the next safe checkpoint. Authored bundle events use the same lane identity model with explicit agent_id, event_source_id, and routing.reactive.
KDCube bundle-copilot Partial The bundle-copilot foundation already exists as a React-based coding bundle and can help scaffold or evolve bundle code. The remaining work is to complete the product experience for bundle builders, including smoother workspace continuity, stronger guidance, and tighter bundle-development flows.
Multi-bundle conversations Partial Technically possible today via routing, but both bundles must understand and agree on the shared conversation format (timeline structure, artifact paths, turn state). Requires alignment between bundle developers on the protocol.
Streaming for operations Available Bundle operations can already stream to connected clients. REST calls can target the initiating peer with KDC-Stream-ID, and bundle code can emit deltas, steps, and widget updates through the communicator over SSE or Socket.IO while the operation is still running.
Claude Code agent: isolated tools, KDCube skills, bundle tools Soon The next Claude Code step is deeper platform-native integration: exposing controlled isolated tools, KDCube skills, and bundle-defined tools inside the Claude Code runtime so bundles can combine Claude's coding loop with the same governed tool surface used elsewhere in the SDK.
Codex agent integration Soon Codex integration is planned as another first-class agent runtime in the platform. The goal is the same accountable, bundle-integrated, UI-visible operating model we now have for Claude Code, but adapted to Codex-specific execution and tool semantics.
Browser navigation toolset for agents Soon A richer browser-navigation tool family for agentic use is the next natural step beyond search and fetch. The goal is to let bundles navigate, inspect, and operate on web flows more deliberately while still staying inside the platform’s controlled tool surface.
Policy DSL Roadmap Relevant for regulated or security-sensitive deployments where operators want to express bundle access restrictions, tenant rules, and data-handling constraints declaratively instead of encoding them in Python hooks. Important governance work, but not on the critical path for the current bundle interface rollout.
Deterministic Enforcement Engine Roadmap Highly complementary to policy work: enforcement decisions should stay deterministic, auditable, and separate from LLM judgment. This is a strong control-plane capability for the platform, especially once policies, gates, and richer workflow contracts grow.
Workflow Invariants Roadmap One of the most immediately relevant roadmap items for agent reliability. Declarative assertions such as “artifact Z must exist before LLM call” or “tool X may only run after step Y” would let the runtime halt bad states before they propagate.
Cross-Agent Approval Gates Roadmap Relevant when multi-agent or supervisor-driven workflows become a primary product surface. The idea is to let a worker or sub-agent pause on a high-impact action and require approval from a coordinator or human operator before continuing.

🎯 Priority: Steer / Followup + KDCube Bundle Copilot

The next high-value product work is to deepen the KDCube bundle-copilot experience so developers can create and evolve bundles directly inside the platform, and to complete the remaining event-source policy phases for richer operator, service, and workflow-originated inputs.

💬
Have feedback or want to prioritize something? Open an issue on the GitHub repo.