KDCube
← Engineering

Named services are the interface that lets an agent enter a new app realm without that agent or its host learning the app's private domain rules.

An app owns a namespace such as task:, mem:, cnv:, repo:, or a future domain. It registers a named-service provider. A consuming app configures which provider operations are allowed for its agent, UI, scene, pinboard, jobs, or external callers. Any compatible agent can then receive the generic tools and harness policies that explore, materialize, render, and mutate objects in that realm. ReAct is one consumer, not the owner. The same interface crosses managed MCP boundaries for external clients and separately consented hosted agents.

The important boundary is this: the shared agent harness owns workspace read/pull adapters, the named_services.* tool calls, timeline storage, strategy governance, and the configured access policy. The provider app owns the object_ref grammar, domain object schema and provider-supported selectors, search spaces, provider API translation, object capabilities/actions, mutation rules, bytes/materialization, block production, and render and compaction projection.

The agent sees one universal interface. The provider remains the owner of meaning.

That ownership begins at definition time. One provider or data API may support several named-service projections when different use cases require different nouns, actions, policy, or presentation. Each provider declaration remains a coherent realm; the shared runtime does not turn an endpoint inventory into one automatic interface. At usage time, the agent reveals only the branch and exact operation the current task needs.

WHO OWNS WHAT — THE OWNERSHIP BOUNDARY Shared agent harness owns one interface — no domain rules inside workspace pull / read adapters ReAct: react.pull / react.read named_services.* tool calls the model-callable surface timeline storage where produced blocks live strategy governance exploration / exploitation ordering configured access policy which operations are allowed, per surface | Provider app owns the meaning of the realm object_ref grammar task:issue:ticket_123 object schema fields · enums · payloads search spaces scopes · filters · scoring capabilities / actions open · preview · download mutation upsert · delete · validate bytes / materialization object.get(response_mode=stream) block production block.produce render / compaction block.render · projection The agent sees one interface; the provider owns meaning.
The agent sees one interface; the provider owns meaning.
THE MAIN INTERFACE — MODEL TO PROVIDER Agent harness model + runtime adapters named_services.* tools explore / exploit (model-callable) workspace pull / read ReAct: react.pull / react.read Consumer app config surfaces.as_consumer discovery / transport NamedServiceProvider domain realm + provider API translation fan-out to provider operations read object.search object.schema · browse / query / exact object.get write object.upsert object.action host_file render infra-called, not model tools block.produce block.render event.resolve One generic path down — the provider translates the realm's language, then executes the read · write · render operations it owns.
One generic path from the model down to the provider — which then fans out into the read, write, and render operations it owns.

Why this exists

Without named services, every agent-facing integration becomes bespoke:

task tools know task schemas;
canvas tools know canvas cards;
memory tools know memory records;
chat chips know how to open each foreign object;
scene code parses mem: or task: prefixes;
Each agent integration gets custom read/render logic for every new subsystem.

That does not scale. It also breaks provenance: once an object is copied into a chat message, pinboard card, or generated file, the system can lose which realm owned the evidence.

Named services solve this by making the owner realm explicit and reusable. For an object_ref = task:issue:ticket_123, the task provider owns the schema, search, preview, open action, mutation, and model-visible rendering — while chat, scene, pinboard, and each agent harness own where the object is shown, which operations are allowed, and how the returned provider result is routed.

object_ref = task:issue:ticket_123

task provider owns:
  schema
  search
  preview
  open action
  mutation
  model-visible rendering

chat, scene, pinboard, and each agent harness own:
  where the object is shown
  which operations are allowed
  how the returned provider result is routed

The object keeps the same object_ref as it moves between chat, scene, pinboard, agent harnesses, MCP/API calls, Data Bus jobs, and cron outputs.

The four agent interfaces

Named services touch an agent in four different places. They should not be collapsed into one "tool" concept.

FOUR PLACES NAMED SERVICES TOUCH THE AGENT 1 Model-callable tools the model decides to call named_services.search_objects named_services.object_schema named_services.upsert_object explore the realm, read schema, mutate 2 Materialization pull → read ReAct adapter shown react.pull(object_ref) object.get(stream) react.read(fi:) → owner block.produce the exact-content path 3 Prompt-render policies what the model actually sees timeline_projection announce_production block.render compaction_projection provider owns visible form 4 UI action path outside the model loop object.resolve object.action(open) ui_event.target_surface scene / pinboard / chat route the result The model-callable tools are only one of four surfaces — the full interface is all four together.
The model-callable tools are only one of four surfaces — the full interface is all four together.

1. Model-callable tools. named_services.provider_about, named_services.search_objects, named_services.object_schema, named_services.upsert_object, and so on.

2. Materialization path. A harness pull adapter calls the provider object.get(response_mode=stream), which writes a fi: workspace artifact; the read adapter then dispatches to the owner block.produce. ReAct exposes these as react.pull and react.read.

3. Prompt rendering policies. timeline_projection, announce_production, compaction_projection, and the provider block.render hook.

4. UI action path. object.resolve and object.action(open/download/preview) resolve a ui_event.target_surface, which the scene, pinboard, or chat route to a mounted component.

The model-callable named-service tools are only one surface. The richer interface is the combination of tools, pull/read, rendering policies, and UI actions.

Note that block.produce, block.render, event.resolve, and object.get(response_mode=stream) are provider operations the shared agent harness calls during materialization and rendering — they are not model-callable named_services.* tools. The named-service tools themselves can be bound to any compatible agent.

How a namespace introduces itself

Before the agent calls any tool, it needs a one-line answer to "what is this namespace, and why would I touch it?" A provider answers that statically: it publishes a namespace intro at registration time.

@named_service_provider(
    provider_id="task.issue",
    namespace="task",
    intro="Project task tracker — issues, status, and attachments. "
          "Search issues, read one, and update status or comments here.",
    ...
)

The intro lands on the provider spec (NamedServiceProviderSpec.intro) and is surfaced to the agent as one line per connected namespace in a roster that is composed into that agent's instructions:

Named-service namespaces available to this agent
(pass one as the `namespace` argument):
- `task` — Project task tracker — issues, status, and attachments. ...
- `mem`  — Durable user memory — facts, preferences, decisions ...
- `cnv`  — Canvas (also called the pin board) — a board of pinned cards ...
HOW A NAMESPACE INTRODUCES ITSELF Provider registers the namespace it owns @named_service_provider( namespace="task", intro="Project task tracker — issues…" operations={…} ) surfaced What the agent sees always present, no tool call NAMESPACE ROSTER · one line each - task — Project task tracker… - mem — Durable user memory… - cnv — Canvas / pin board… ALONGSIDE THE CANONICAL TOOLS search schema upsert then, on demand provider.about the deep description · searchable scopes · ref grammar · domain language The intro is the realm's self-introduction — always there, costs no call, tells the agent which namespace to reach for. provider.about is the on-demand deep dive fetched after the agent decides to engage. Roster label order: introlabel → namespace name.
The roster intro tells the agent which namespace to reach for; provider.about is the on-demand deep description it fetches after deciding to engage.

This is the realm's self-introduction. It is always present and costs no tool call, which makes it different from provider.about: the roster intro tells the agent which namespace to reach for, while named_services.provider_about is the on-demand deep description — searchable scopes, ref grammar, domain language — the agent fetches once it has decided to engage.

Shipped providers already publish intros: memory registers intro=MEMORY_NAMESPACE_INTRO, and canvas carries CANVAS_NAMESPACE_INTRO. When a provider publishes no intro, the roster falls back to the provider label, then to the bare namespace name.

This is also a lifecycle distinction: the intro lives in the system instruction and is never pruned, while provider.about and object_schema are timeline reads the platform compacts over time and the agent re-fetches on demand.

For how intro, provider.about, and object_schema divide the labor, see the Short "How a named service introduces itself."

Provider registration

A provider app registers the namespace it owns and the operations it supports. The registration is not just a Python class. It is the public contract for how the realm can be searched, read, displayed, and changed.

@named_service_provider(
    provider_id="task.issue",
    namespace="task",
    intro="Project task tracker — issues, status, and attachments. "
          "Search issues, read one, and update status or comments here.",
    operations={
        "provider.about": {"transports": ["bundle_registry"]},
        "object.search": {"transports": ["bundle_registry"]},
        "object.schema": {"transports": ["bundle_registry"]},
        "object.get": {"transports": ["bundle_registry"]},
        "object.resolve": {"transports": ["bundle_registry"]},
        "object.action": {"transports": ["bundle_registry"]},
        "object.upsert": {"transports": ["bundle_registry"]},
        "object.delete": {"transports": ["bundle_registry"]},
        "block.produce": {"transports": ["bundle_registry"]},
        "block.render": {"transports": ["bundle_registry"]},
    },
    search_scopes=[
        {
            "namespace": "task:issue",
            "label": "task issues",
            "object_kind": "task.issue",
        },
        {
            "namespace": "task:attachment",
            "label": "task attachments",
            "object_kind": "task.attachment",
        },
    ],
)
class TaskIssueProvider(NamedServiceProvider):
    ...

At app startup, the app exposes a registry:

def named_services(self):
    registry = NamedServiceRegistry()
    registry.register(TaskIssueProvider(...))
    return registry

The runtime records the provider in Named Service Discovery. Consumer apps do not need to hardcode the provider location. They configure access to the namespace and let discovery pick the registered provider for the current tenant/project context.

REGISTRATION & DISCOVERY — PROVIDER REGISTERS, CONSUMER RESOLVES PROVIDER APP — ON LOAD build named_services() provider registry register provider id · namespace · operations · search scopes writes record Named-Service Discovery stores the provider record task.issue → provider · ops CONSUMER APP — CALL namespace task discovery resolves finds the task.issue provider namespace → provider record NamedServiceClient calls object.search · object.upsert · … on the resolved provider reads record The provider writes its record into discovery; the consumer resolves a namespace through the same node — no direct coupling between the two apps.
On load the provider builds its named_services() registry and lands a record in the discovery table; a consumer call on the namespace is matched to that provider and a NamedServiceClient invokes the owned operations.

One namespace, several providers

A namespace names a domain, not an app. When two apps genuinely hold objects of the same domain, each registers its own provider for the same namespace and declares which part it serves: which operations, and which ref shapes. Discovery keeps both records — each app's registration updates only its own — and routes every call by what was declared:

call: namespace + operation (+ object_ref)
  -> providers that declared this operation
  -> providers whose declared ref pattern matches the ref
  -> the most specific ref match answers

The linkedin namespace is the shipped example. The built-in publishing provider serves connected accounts and published posts — its post ids are LinkedIn URNs (linkedin:<account>:post:urn:li:…) — and owns the publish and comment actions. An app that keeps an authored publications store serves the same namespace with store-shaped post ids that always carry a slash (linkedin:<account>:post:<fold>/<slug>), answering search, read, and its own open/download actions there. The id shape is the partition: a URN never contains a slash, so on every operation both providers declare, each ref routes to exactly one provider — the store cannot take a publish call, and the publisher cannot take a store read. Keeping those shapes disjoint is the providers' own contract; the routing simply follows the declared patterns.

For the agent, nothing changes. It still names one namespace and calls the same generic tools; the platform selects the provider per call. The service card users see for the namespace shows the union of what every provider declares — one namespace stays one card. A caller that wants one specific provider can name it on the call, but the id shape normally decides.

Consumer registration for agents

The consumer app decides which namespace operations its agent may use. This is configured under surfaces.as_consumer, because the agent is one consumer surface among others.

surfaces:
  as_consumer:
    default_agent: main
    agents:
      main:
        tools:
          - id: task_service
            kind: named_service
            alias: named_services
            namespaces:
              task:
                allowed:
                  - provider.about
                  - object.search
                  - object.schema
                  - object.get
                  - object.host_file
                  - object.upsert
                  - object.delete
                tool_traits:
                  provider_about:
                    strategy: [exploration]
                  search_objects:
                    strategy: [exploration]
                  object_schema:
                    strategy: [exploration]
                  get_object:
                    strategy: [exploration]
                  host_file:
                    strategy: [exploitation]
                  upsert_object:
                    strategy: [exploitation]
                  delete_object:
                    strategy: [exploitation]

        event_sources:
          - kind: named_service
            namespace: task
            enabled: true
            discovery:
              mode: service_discovery
            policies:
              pull:
                mode: provider
                operation: object.get
              block_production:
                mode: provider
                operation: block.produce

    ui:
      canvas:
        resolvers:
          - kind: named_service
            namespace: task
            enabled: true
            discovery:
              mode: service_discovery
            allowed: [object.resolve, object.action]

There are three separate permissions here. agents.<agent>.tools exposes the model-callable named_services.* operations. agents.<agent>.event_sources enables the harness pull/read/render path for owner objects. ui.canvas.resolvers enables the scene / chat / pinboard object.resolve and object.action.

agents.<agent>.tools — model-callable named_services.* operations
agents.<agent>.event_sources — harness pull/read/render path for owner objects
ui.canvas.resolvers — scene/chat/pinboard object.resolve and object.action

A namespace can be pullable by a harness adapter such as react.pull without exposing named_services.get_object to the model. A namespace can support UI open actions without exposing object_action as a model-callable tool.

Across agent boundaries: external and hosted delegated agents

Everything above describes direct consumers inside KDCube: hosted agents, scene, widgets, and pinboard. The same contract can cross a managed MCP boundary in two delegated-client positions: an external OAuth client such as Claude Code, and a hosted agent identified as kdcube-agent:<app>:<agent_id> with its own per-user, per-agent grant.

Both appear under Delegated by KDCube, but their grants remain separate. Granting one hosted agent grants nothing to a sibling, and the user's browser session never enters either client.

The important thing is what does not change. This is not a second named-services system and not a unguarded API. The provider still owns meaning; the external client is simply one more consumer of the same provider contract — it calls the same search / get / about / schema operations, through the same discovery, and never learns the realm's private rules. What sits in front of it is a guarded door, not an open one:

external client or hosted agent
      |
      |  MCP tools/call + its own delegated credential
      v
Connection Hub managed guard   — checks resource · tool · grants · consent
      |
      v
the same named-service provider contract   — search / get / about / schema
THE INTERFACE NOW FACES BOTH WAYS NamedServiceProvider contract · domain owner about schema search get INTERNAL · INSIDE THE RUNTIME agent harnesses hosted agents scene / widgets UI / app surfaces pinboard proxy cards reach the provider directly outside provider boundary delegated app external / hosted managed guard bound credential resource · tool grants · consent must pass the guard first Same contract, two directions: direct consumers reach it in-process; a delegated client enters only through the guarded door. The interface crosses the KDCube boundary without becoming an unauthenticated public API.
Same provider contract: direct consumers stay in-process; delegated clients cross a managed, grant-bound door.

Three properties keep this honest, and each is the subject of an earlier piece in this series, so here they are only named:

  • the bearer is a delegated credential — scoped to one resource, recorded, per-user, revocable — categorically not a shared secret (see Authenticated MCP In KDCube: Delegated Credentials, Not Shared Secrets);
  • the managed guard checks resource, tool, grants, and consent on every call before provider code runs (see Protecting KDCube Surfaces With Managed Credentials);
  • the external agent stays its own actor, reaching the user's data through an explicit consented edge, never becoming the user's session (see Connected Identities Are Not One User Id).

A hosted delegated connection distinguishes the concrete endpoint from its grant key:

- name: slack
  kind: mcp
  server_id: slack
  url: https://runtime.example/api/integrations/bundles/<T>/<P>/kdcube-services@1-0/public/mcp/named_services
  resource: "*/api/integrations/bundles/*/*/kdcube-services@1-0/public/mcp/named_services*"
  transport: streamable_http
  delegated: true
  scopes: [named_services:use]

url is what the client dials. resource is the exact delegated-resource id from Connection Hub's catalog: grant creation and lookup use that id, while the guard matches the request URL against it. Each turn the host retrieves the bearer already bound to this user-agent-resource grant. With no grant, it makes no MCP contact.

That scopes list is intentionally only the MCP admission grant. The bridge derives each namespace operation's requirements from the Connection Hub catalog when the operation is attempted. Provider-backed claims such as slack:post live on the caller's per-account account_scope; a matching account claim satisfies the bridge operation gate without being duplicated in outer resource_grants, and the provider broker enforces the same account binding before the upstream call.

Provider-backed namespaces add a second consent. The agent grant admits the agent to the KDCube namespace boundary; the provider adapter checks the user's connected Mail, Slack, or Google account and claims on every call. Revoking either stops the operation, and the provider token never enters the agent.

The live account projection is bound again at each named-service tool-call entry. Streamable MCP dispatch may invoke a tool in a later async context than the one that built the MCP app; rebinding at invocation keeps the current pointer-backed grant card authoritative all the way to the provider resolver.

A bundle opts a realm into this by declaring a managed MCP surface in its descriptor — the same surfaces.as_provider shape, marked mode: managed:

surfaces:
  as_provider:
    mcp:
      memories:
        auth:
          mode: managed
          authority_id: delegated_client
          tools:
            memory_search: { grants: [memories:read] }
            memory_get:    { grants: [memories:read] }
          selected_tool_grants: true

What ships today: the durable memory realm shipped first — the user-memories@2026-06-26 bundle exposes a per-realm MCP surface with memory_search and memory_get, each declaring the grant it needs. Then a generic surface arrived: kdcube-services@1-0 exposes several namespaces through one named_services door, where the agent calls generic named_services_search / named_services_get / named_services_call and names the namespace. Conversations (conv), Mail, Slack, and Google Sheets are wired examples, and every operation is still grant-gated. Other application namespaces use the same pattern. Conversations is also the realm that exercises the hosted-file boundary below. The concrete connector, and the two-boundary grant model that keeps a generic bridge specific, are covered in the companion Short Named services can now leave KDCube through a delegated MCP connector.

The through-line is the same one this whole article makes: the agent gets one generic interface and the provider stays the owner of meaning — and Connection Hub lets that interface cross the KDCube boundary without turning it into an unauthenticated public API.

Complete data crosses out of band

Inside the runtime, complete data reaches an agent through a harness pull adapter → a streaming object.get → a workspace artifact. ReAct exposes that adapter as react.pull; the Pull, Read, And Owner Block Production section below covers it. An external agent has no local harness pull adapter. It calls object.get and receives a JSON tool result, so large content needs a separate delivery path.

For hosted framework agents, KDCube also normalizes the result boundary. When MCP v2 carries a provider JSON object inside one content[].text block, the client unwraps it before returning the tool value. The model sees the direct {ok, error, ret} payload and the timeline receives a compact result summary, not a transport envelope containing escaped JSON.

That single fact reshapes how a file-bearing realm behaves at the boundary. An MCP tool result is JSON, so bytes can only ride inline as base64 — and base64 lands in the model's context. A 143 KB chart becomes ~190 KB of base64 the model must hold, decode, and walk. That is the wrong place for bytes.

So a realm answers object.get with a compact envelope and a way to continue:

object.get conv:fi:conv_<id>.turn_<id>.outputs/chart.png
  ->
  { ref, filename, mime, size, encoding, ... }

encoding = text    content is the decoded text (inline). Small, context-safe.
encoding = url     fetch the bytes from `url` over HTTP — a short-lived signed link.
                   The default for binaries: bytes never enter the model's context.
encoding = base64  content is base64 (small binaries only).
next_cursor        continue a long collection without truncating it.

The link is session-less and signed: its token is bound to the exact ref, requester, tenant/project, and expiry. What happens after verification depends on the object. A KDCube-hosted artifact streams its stored bytes. A connected Mail, Slack, or Google Sheets object resolves the user's current provider credential and consent server-side, then fetches current provider data. The provider token never enters the URL or result. The caller grant is checked when the URL is minted; the URL itself is the short-lived download capability, while provider consent is checked again on each use. A repeated live-provider fetch may therefore see newer data; a harness pull creates the stable turn snapshot.

The outside forces one more rule: the ref must be self-contained. Inside the runtime a bare fi:turn_... resolves against ambient conversation state. The external agent has none, so the realm scopes every emitted file ref to its conversation — conv:fi:conv_<id>.turn_<id>.<...>. A ref that only resolves with server-side ambient state cannot be reached from outside; a self-contained ref round-trips, so what search or object.get emits is valid input to the next object.get.

COMPLETE DATA STAYS OUT OF MODEL CONTEXT external agent your own Claude object.get(ref) provider get metadata + delivery ref · type · signed URL returns JSON MODEL CONTEXT only compact JSON lands here ref · schema · metadata short-lived download URL complete data hosted bytes or live snapshot HTTP GET · out of band The client fetches the full object only when needed. Its model context remains the client's decision. Hosted refs stream stored bytes; connected refs resolve current consent and provider access server-side. The URL carries no provider token; the object ref remains self-contained and round-trippable.
Compact discovery stays in the tool result; complete authorized data crosses through a signed KDCube delivery boundary.

Conversations is the realm that exercises all of this: search a conversation, read its timeline, and its produced files and uploaded attachments come back as conv:fi: refs; object.get a text one and it inlines, object.get a binary one and you get a link, not a base64 blob.

The reverse direction uses the same boundary. When a provider action needs an existing conversation file, the payload names the durable source ref, for example file_path: conv:fi:.... The provider side materializes the bytes under the current actor, grantor, tenant, project, conversation, and operation/account authority before it streams them to Slack, Mail, Drive, or any other backing service. A local ./file produced by a harness pull is only a sandbox path; it is not a provider-visible file source. Turn-less clients use the staging contract instead: request an upload target, PUT the bytes there, and pass the returned staged_ref; inline content_base64 with a filename stays a last resort for tiny generated files.

Canonical named-service tools

The generic named-service tool adapter exposes these model-callable tools to any compatible agent when the consumer config allows the corresponding provider operations.

Canonical named-service tools — model-callable surface mapped to provider operations.
Tool Provider operation Usual strategy Purpose
named_services.provider_about provider.about exploration Understand what the provider owns and which objects/scopes it exposes.
named_services.list_objects object.list exploration Page through a bounded collection when listing is meaningful.
named_services.search_objects object.search exploration Search a base namespace or provider-declared search scope. Emits generic search-result context rows.
named_services.get_object object.get exploration Read one compact object envelope. Complete content remains reachable through cursors, signed delivery, or the host harness's pull/read adapter.
named_services.object_schema object.schema exploration Ask the provider for fields, object kinds, filters, upsert/delete payloads, and usage guidance.
named_services.host_file object.host_file exploitation by default Move a harness artifact/file into provider-owned storage and receive a provider-owned ref.
named_services.upsert_object object.upsert exploitation by default Create or update a provider object according to provider schema.
named_services.delete_object object.delete exploitation Delete, archive, retire, or suggest deletion according to provider policy.
named_services.object_action object.action action-dependent Run bounded provider actions such as preview/open/download/describe. Usually used by UI resolvers, not exposed broadly to the model.

The strategy column is not hardcoded truth. It is deployment policy. For example, memory recording may be configured as neutral if the product treats it as side-memory rather than an answer-changing write. A provider action that only describes an object may be exploration, while a provider action that sends mail or changes status is exploitation.

The agent sees traits in the tool catalog:

Scope:
    - namespaces applicable: task, mem, cnv
    - strategy: exploitation (default)
    - strategy overrides by namespace:
        - mem: neutral

That trait can be used by an agent's online governance. ReAct's current governance classifies every tool against four trait values — exploration, exploitation, neutral, and unknown. In the same model round, exploration -> exploitation is denied because the exploitation would depend on a result that has not yet been shown to the model. exploitation -> exploration is allowed for staged work.

Searchable namespaces and search scopes

A namespace is not necessarily one flat object space. A provider can declare search scopes:

task
  task:issue
  task:attachment

sensor
  sensor:temperature
  sensor:humidity:aggr

cnv
  cnv

Search scopes are provider registration metadata. They let the tool catalog show the model where search is meaningful without making a live provider.about call first.

search_scopes:
  - namespace: task:issue
    label: task issues
    object_kind: task.issue
    filters:
      status:
        type: string
        enum: [open, in_progress, blocked, resolved]

The consumer authorizes the base namespace:

namespaces:
  task:
    allowed: [object.search]

The model can then call a scoped namespace:

{
  "namespace": "task:issue",
  "query": "membership cancellation",
  "filters": "{\"status\":\"open\"}"
}

The provider owns what the scope means. Search may be lexical, semantic, hybrid, RRF-ranked, memory-weighted, or something domain-specific. The provider also owns filter names and relevance tuning. The search_objects tool always sends search_mode="hybrid" on the wire; the provider decides what hybrid means for its realm and may ignore the hint. These are provider capabilities; the named-services layer does not build an index over the provider's object space.

SEARCH SCOPES — DECLARED BY THE PROVIDER, AUTHORIZED BY THE CONSUMER Provider namespace task declares search scopes: task:issue label · filters · object_kind task:attachment label · filters · object_kind Consumer config authorizes the base op: object.search namespaces: task: allowed: [object.search] Model may call a scoped namespace: task:issue query: "membership cancellation" filters: {status: open} Scopes appear in the tool catalog without a live provider.about call. They are provider registration metadata — the catalog already knows where search is meaningful. The provider owns what the scope means — lexical, semantic, hybrid, RRF, memory-weighted — plus filter names and relevance tuning.
Scopes appear in the tool catalog without a live provider.about call.

Object schema is the working contract

The agent should not infer object bodies, selectors, or actions from visual cards and old examples. It can enter a large schema progressively instead of loading every action contract at once:

named_services.object_schema(namespace="docs")
  -> recursive capability catalog root

named_services.object_schema(namespace="docs", schema_path="/discussion")
  -> one catalog branch

named_services.object_schema(
  namespace="docs",
  query="reply to a comment on a tab",
  search_mode="hybrid",
  limit=10,
)
  -> matching catalog paths, object kinds, and stable operation ids

named_services.object_schema(
  namespace="docs",
  object_kind="docs.comment",
  schema_operation="object.action:reply_comment",
)
  -> the exact payload, result, selector, and authority contract
USAGE TIME - REVEAL ONLY WHAT THE TASK NEEDS The catalog stays compact; one exact contract is expanded just before the operation. 1 · INTENT Hosted or external agent "reply to a comment" 2 · CHOOSE REALM namespace intro provider.about what / when / refs 3 · FIND CAPABILITY object_schema root / path / query declarations only 4 · MATCH catalog_path object_kind schema_operation 5 · EXPAND EXACT payload + result selectors + claims one operation 6 · OPERATE search / get upsert / action generic grammar PROVIDER-OWNED CAPABILITY DECLARATION INDEX catalog labels · descriptions · keywords · kinds · operation ids lexical always; semantic / hybrid when an embedding service is bound contains no documents, messages, tasks, or other provider objects PROVIDER OBJECT SPACE objects · provider IDs · state · bytes object search follows provider semantics adapter enforces the exact contract Capability discovery searches the provider's declared language. Object operations cross the governed boundary into live provider data. The agent expands one precise contract, then acts; it never receives the provider's full API inventory.
Capability discovery searches the declared language; object operations cross into live provider data.

The provider owns the hierarchy and may nest catalogs to any useful depth. Capability search runs over labels, descriptions, keywords, kinds, paths, and operation ids from that app-owned declaration. The declaration is small and stable enough to prepare in shared bundle storage at load time; when semantic search is unavailable, the response reports lexical fallback. No document, message, task, or other provider object enters this index.

The provider-owning bundle prepares one immutable, timestamped index generation whose identity covers the declaration and embedding profile. Unchanged loads reuse it; a catalog or embedding-profile change creates the next generation. The newest successful loader keeps the current generation and its immediate predecessor, then removes older file families. Consumer agents only query this prepared declaration index.

The schema can describe:

object kinds;
relationships between those objects;
provider-supported selectors and ambiguity rules;
create/update fields;
strict enums;
open vocabularies with known/suggested values;
filters for search/list;
bounded actions and their payloads;
required claims and capability limits;
mutation payload examples;
optimistic concurrency requirements;
required object_ref shape;
provider-specific delete/archive semantics.

This is the realm's working language, not a copy of the provider's REST reference. A document realm can describe documents, tabs, comment threads, replies, and exports, then declare selectors such as tab title or ordinal and comment text, author, or status. When those selectors are supported, the provider adapter resolves them to opaque provider IDs and sequences the required API calls. The agent and user work in domain terms.

Domain terms do not imply a KDCube index over provider objects. Object search through search_objects remains limited to the provider's own search capabilities and explicit predicates over bounded data returned by that provider. A meaning-based object selector is declared only when the provider can execute it. This is separate from object_schema(query=...), which searches only the capability declaration.

The schema must also be honest about the current adapter. If a selector is ambiguous, the provider returns choices. If an operation is unavailable or preview-only, it says so precisely. It must not present a document-level comment as though it were attached to a specific tab.

The shipped docs namespace follows that rule. Tab edits accept title, literal title fragment, 1-based position, or hierarchy. Document-level comment actions accept literal text, author (me included), resolved state, or position. Ambiguous matches return bounded candidates; tab-scoped comment requests return tab_anchored_comments_unavailable.

For a mutation, the model then calls the generic tool with provider-schema JSON:

{
  "namespace": "task",
  "object_ref": "task:issue:ticket_123",
  "base_revision": "7",
  "object_json": "{\"status\":\"blocked\",\"comment\":\"Waiting for contract confirmation.\"}"
}

Payload fields are provider-owned. They do not control named-service routing. Routing is controlled by the tool name, configured namespace, operation, and provider discovery.

The object-mutation dialect: add, replace, remove

Knowing the fields is only half of a mutation. The other half is how a field changes when upsert_object carries it — does a value replace what was there, add to it, or take an item off it? Named services answer this with one small dialect that holds for every realm.

Scalars are set-if-provided. A provided value replaces the old one; an omitted field is left untouched. To leave a field unchanged, do not send it.

Collections declare an update_strategy. Each list or map field says in the schema how its bare value is applied. An array is either append (the list you send is added to the existing one) or replace (it swaps the whole list). A map/object is either patch (the keys you send are merged in, the rest are kept) or replace (the whole object is overwritten). Read the strategy before a bare-list update — it is the difference between adding to a field and silently overwriting it.

dedup_key supersedes one item. An append list may declare a dedup_key — an item-identity attribute, e.g. a task attachment keyed by filename. Adding an item whose key matches an existing one in the same parent replaces that one item rather than duplicating it. So "replace one item" is just "add it again with the same dedup_key" — no add-then-delete dance.

The {add, remove} delta edits incrementally. Any collection field also accepts a { "add": [...], "remove": [...] } delta, so the agent can add or drop items without re-sending the whole list. Removes apply first, then adds. Crucially, removing a list item is the {remove} delta — by value for value-lists, by ref or dedup_key for ref-lists — and for a ref-list it only detaches the item from this parent. That is not delete_object: delete_object destroys the underlying object itself (the file/record everywhere it is used) and is a separate, rarer operation, never the way to take an item off a list.

The mutation dialect — field type maps to a strategy and a way to set it.
Field type Strategy How to set it
scalar set-if-provided Send the value to replace; omit the field to preserve it.
array append / replace Bare list either adds to the existing list or swaps the whole list, per the field's strategy.
array + dedup_key append with supersede Add an item whose dedup_key matches an existing one to replace that single item in place.
map / object patch / replace patch merges the keys you send; replace overwrites the whole object.
any collection {add, remove} delta Send { "add": [...], "remove": [...] } to add or drop items without re-sending the list. {remove} is how you take an item off a list — not delete_object.
TWO LAYERS TEACH ONE DIALECT — ADD · REPLACE · REMOVE GLOBAL DEFAULT named-services agent instruction bare-list · {add, remove} · dedup_key · removal rules PER-FIELD SETTING each list/map attribute · object schema update_strategy + optional dedup_key agent mutates the object upsert_object · field rules applied scalar set value · omit keeps list append / replace whole list dedup_key key match replaces one item {add, remove} incremental item delta The instruction carries the universal rules; the schema carries the per-field specifics — remove an item with {remove}, not delete_object.
Two layers teach one dialect: universal rules in the instruction, per-field specifics in the schema.

The rules reach the agent two ways. First a global default: the bare-list / {add, remove} / dedup_key / removal rules are injected into the named-services agent instruction, present whenever named-services tools are connected — so the agent applies add/replace/remove correctly without reading each schema in detail. Then a per-field setting: each list/map attribute in the object's schema declares its own update_strategy (and optional dedup_key), the realm-specific refinement the agent reads when it needs the exact behavior for that field. The universal rules live in the instruction; the per-field specifics live in the schema.

Pull, read, and owner block production

For a harness agent, the normal named_services.get_object result is not the complete-content path. The shared materialization path, exposed by ReAct as react.pull and react.read, is:

react.pull(paths=["mem:record:mem_123"])
  -> namespace rehoster
  -> provider object.get(response_mode=stream)
  -> local artifact:
       logical_path  = fi:turn_1.files/mem_123.json
       physical_path = turn_1/files/mem_123.json
       object_ref    = mem:record:mem_123
       scope         = files or snapshots

react.read(paths=["fi:turn_1.files/mem_123.json"])
  -> generic read target with object_ref
  -> owner event source resolution
  -> provider block.produce
  -> model-visible owner blocks

scope is workspace materialization metadata. It is not the owner namespace. The owner identity remains object_ref.

PULL / READ — THE EXACT-CONTENT MATERIALIZATION PATH react.pull (object_ref) provider object.get response_mode=stream local fi: artifact logical_path = fi:turn_1.files/mem_123.json object_ref = mem:record:mem_123 (PRESERVED) scope = files | snapshots scope = workspace materialization, not the namespace react.read (fi:turn_1.files/mem_123.json) → object_ref carried owner block.produce read target → timeline blocks model-visible owner blocks get_object ≠ pull/read get_object is a bounded envelope; pull/read materializes bytes. react.pull materializes exact bytes; react.read dispatches to the owner to produce model-visible blocks — the object_ref is carried the whole way.
react.pull materializes bytes with object_ref preserved; react.read hands them to the owner's block.produce.

For a volatile object, the owner may intentionally produce only a compact timeline fact and put the rich current view in ANNOUNCE. Canvas is the reference example:

react.pull(paths=["cnv:main"])
  -> fi:turn_1.snapshots/cnv/main.json

react.read(paths=["fi:turn_1.snapshots/cnv/main.json"])
  -> [CANVAS TOOL RESULT] timeline fact
  -> [CANVAS BOARD] in ANNOUNCE for N render rounds

The compact fact tells the model how to refresh:

announce_effect: board projection refreshed in ANNOUNCE for 3 render rounds
refresh_rule: use react.pull(paths=['cnv:main']) and react.read on the returned fi: path if you need an updated or prolonged board view

This text belongs to the canvas owner policy. Generic ReAct does not know that cnv: means canvas or that the object is volatile.

For stats_only reads, the owner can emit top-level original_object_stats. ReAct copies that into the stats response without interpreting domain fields.

The same shared materializer can be attached to another agent implementation. The ported LangGraph app is the worked example: LangGraph keeps its agent loop while KDCube supplies the turn workspace and provider-ref pull path.

Rendering policies: what the model sees

Named-service integration is not complete unless the provider controls model-visible representation. There are several policy surfaces.

block.produce
  called during react.read
  turns one object/read target into bounded timeline blocks

timeline_projection
  render-time mutation of already-produced timeline blocks
  good for compact facts instead of raw JSON

announce_production
  non-durable prompt-tail context
  good for current focused state, board maps, active forms

block.render
  provider hook called during prompt rendering
  can patch provider-owned visible blocks

compaction_projection
  representation for summarizer/compaction input
  should preserve stable refs and facts, not prompt-only ANNOUNCE text
RENDERING POLICY SURFACES — EACH ANSWERS ONE QUESTION WHAT TO SEE NOW block.produce called during react.read "What becomes a timeline block?" timeline_projection render-time block mutation "How does it read compactly?" announce_production non-durable prompt tail "What focused state to surface?" block.render prompt-render patch hook "What blocks to patch now?" These four shape the live, visible view — what the model needs in order to act this round. WHAT SURVIVES COMPACTION compaction_ projection input for the summarizer "Which stable refs & facts survive?" Seam exists today; some preservation still hardcoded. Normal render asks "what should the model see now to act?" — compaction render asks "what stable facts and refs should survive when this block leaves the visible window?"
Four policies shape what the model sees now; compaction_projection shapes what survives compaction.

The compaction phase seam exists in the code today: the compaction_projection hook is already wired so providers can shape summarizer input, though some preservation is still hardcoded rather than provider-owned. The direction is that providers fully own compaction rendering. A task provider can decide which issue fields survive summarization. A memory provider can decide which salience/freshness fields matter. A canvas provider can preserve cnv:main@52 and selected card ids while not treating the rendered ANNOUNCE board map as authoritative state.

Compaction projection answers a different question than normal prompt rendering:

normal render:
  What should the model see right now to act?

compaction render:
  What stable facts and refs should survive when this block leaves the visible
  window?

Provider policies should therefore keep recovery anchors explicit:

object_ref: task:issue:ticket_123
revision: 7
status: blocked
recover_with: react.pull(paths=["task:issue:ticket_123"])

UI actions and pinboard

The same provider contract is used outside the model loop. A canvas card stores object_ref = task:issue:ticket_123. When the user clicks open, canvas/scene calls object.action(open, object_ref). The task provider returns a ui_event.target_surface = task_tracker.issue_editor with an action and payload, and the scene routes that target_surface to the mounted component alias.

canvas card stores:
  object_ref = task:issue:ticket_123

user clicks open:
  canvas/scene calls object.action(open, object_ref)

task provider returns:
  ui_event.target_surface = task_tracker.issue_editor
  ui_event.action = open
  ui_event.payload = { issue_id: ... }

scene routes:
  target_surface -> mounted component alias
UI ACTION PATH — CARD TO MOUNTED COMPONENT canvas card stores object_ref: task:issue:ticket_123 object.action (open, object_ref) scene passes full ref provider resolves ui_event.target_surface task_tracker.issue_editor scene surface command target_surface → alias mounted component The scene passes the full object_ref; the provider decides the action. Canvas and scene never decide that task: means "issue editor" — namespace presentation gives only colors, icons, labels.
The scene passes the full object_ref; the provider decides the action.

Canvas and scene do not decide that task: means issue editor. They pass the full object_ref to the provider resolver. Namespace presentation config provides colors, icons, and labels. Provider resolver results provide capabilities and actions.

Pinboard stores proxy cards. The card layout belongs to canvas. The referenced object still belongs to its provider realm.

Pinboard card
  layout: x/y/w/h, comments, description, selected state
  object_ref: mem:record:mem_123
        |
        v
memory provider
  preview/open/read/search/mutate memory record

How to add a new agent realm

For a new app realm, implement this checklist.

Provider app:

  1. Choose canonical object_ref grammar.
  2. Define object kinds and namespace presentation config.
  3. Implement NamedServiceProvider.
  4. Expose named_services() registry.
  5. Register provider operations and search scopes.
  6. Implement provider.about and a progressive object.schema projection: root catalog, recursive paths, capability query, kind view, and exact operation expansion.
  7. Declare a coherent schema_projection_index; when one backend serves materially different use cases, publish separate provider projections rather than one flat endpoint inventory.
  8. Implement search/list/get as needed.
  9. Return next_cursor for long collections and a signed complete-data path for external clients when normal results are compact.
  10. Implement object.get(response_mode=stream) if a harness agent should pull objects.
  11. Implement block.produce for harness reads.
  12. Implement block.render if provider-owned blocks need prompt-time patches.
  13. Implement compaction_projection policy or provider compaction renderer when the realm needs custom summary/recovery shape.
  14. Implement object.resolve and object.action for UI surfaces.
  15. Implement mutation operations only with provider-side schema validation; declare exact action variants for independent grants.
  16. Declare the presentation layer in spec metadata (purpose, works-with, human labels per operation/action, object-kind one-liners; connected-account requirements for provider-backed realms) — the same self-description now has a SECOND reader: the capability picker renders it as the realm's service card, where users understand and narrow the realm per operation.

Consumer app:

  1. Add namespace under surfaces.as_consumer.
  2. Allow only the operations each agent/surface needs.
  3. Configure tool_traits for ReAct governance.
  4. Configure named-service event source/pull/block-production policy.
  5. Configure UI resolvers for canvas/chat/pinboard/scene.
  6. Add scene surface command contracts for provider-returned target_surface values.
  7. Test discovery logs, tool catalog, search scopes, object actions, pull/read, and compaction behavior.

Useful diagnostics

When the agent cannot see a provider tool:

check surfaces.as_consumer.agents.<agent>.tools
check namespace allowed operations
check named_services tool catalog
check strategy traits rendered in tool scope

When a provider cannot be found:

Named-service discovery provider scan
Named-service endpoint resolution
selected_provider: <none>
reason: no matching provider

When search scopes are missing:

[named_services.tools.search_scopes]
registry_keys=...
discovery_entries=...
entry_scopes=...
result_counts=...

When a pulled object reads as a generic file:

react.read.owner_projection
status=no_event_source | no_blocks | policy_error | produced

When a UI card stays "resolving":

object.resolve / object.action logs
provider ui_event present?
namespace presentation config present?
surfaceCommandContracts target_surface mapped?

When a canvas board appears twice in ANNOUNCE:

check whether chat.canvas.state and canvas.read both refer to same cnv:<board>
owner announce policy should consolidate by object identity

Related publications

Definition, discovery, and the managed boundary that lets this interface face outward:

How a named service introduces itself: intro, about, schema — the progressive discovery path and domain-schema boundary.
Create a Named Service: Teach the Platform Your Domain — the definition-time projection from provider API to one or more use-case realms.
Connect Your Named Services To Claude Code — the same progressive contract over the external MCP surface.
Protecting KDCube Surfaces With Managed Credentials — the managed guard and descriptor model.
Connected Identities Are Not One User Id — the actor/grantor distinction.
KDCube Deep · 24.06.2026