Create a Named Service: Teach the Platform Your Domain
The complete hands-on authoring story: project one backend into coherent use-case realms, then declare nouns and refs, recursive capability discovery, exact guarded actions, complete data delivery, and the presentation layer — and the test that a realm now has two readers: an agent that works it from the schema, and a user who understands and controls it from its service card.
Your team runs a real domain — shipments, contracts, lab samples, campaigns. Agents should work it, users should control it, UIs should open its objects. The usual path is thirty bespoke tools and a custom admin page. The KDCube path is one realm declaration.
The ontologic-tools piece sketched the recipe on a logistics desk; this is the hands-on build — every declaration a realm author makes, in order, and the two readers who consume them.
A provider API does not force one universal realm. The same backend can support several named-service interfaces when the use cases need different language, objects, actions, or authority. A freight operator may need shipments and rescheduling; an auditor may need cases, evidence, and export. Those are two coherent provider declarations over shared API mechanics, not one endpoint catalog with every possible operation mixed together.
1. Name the nouns
A realm starts with object kinds and a ref grammar. Refs are identities that round-trip with no ambient session — any surface that holds one can come back to the object.
NAMESPACE = "freight"
SHIPMENT_KIND = "freight.shipment" # freight:shipment:<id>
EXCEPTION_KIND = "freight.exception" # freight:exception:<shipment>:<seq>
POD_KIND = "freight.pod" # freight:pod:<shipment> (proof of delivery)
Each kind gets a one-liner — it teaches both readers later:
OBJECT_KIND_DESCRIPTIONS = {
SHIPMENT_KIND: "One shipment on the desk, with its route and status.",
EXCEPTION_KIND: "One exception raised on a shipment (delay, damage, customs hold).",
POD_KIND: "One proof-of-delivery document attached to a shipment.",
}
2. Write the questions
Search scopes declare what the desk already asks, with self-described filters — the
agent reads these from object_schema and composes queries on the first
try:
FREIGHT_SEARCH_SCOPES = (
NamedServiceSearchScope(
namespace=NAMESPACE,
label="shipments",
object_kind=SHIPMENT_KIND,
description="Search shipments by route, carrier, status, and overdue time.",
filters_schema={
"status": {"enum": ["planned", "in_transit", "delivered", "exception"]},
"carrier": {"type": "string"},
"overdue_days": {"type": "integer", "minimum": 0},
},
),
)
3. Arrange the capability catalog
Search scopes answer which objects match? The capability catalog answers a different question: what can this realm do? Put every projected operation in a provider-owned tree of stable paths.
Start from domain effects, not endpoint count. Several provider API calls may implement one user-meaningful operation. Operations with different effects or grants remain separate. The catalog organizes that deliberate operation vocabulary; it does not infer it from an API specification.
Keep one projection coherent. When two audiences need different nouns, actions, or grants, publish separate named-service providers or namespaces over the shared adapter. Each projection gets its own catalog and presentation instead of making every caller navigate one provider-wide API inventory.
FREIGHT_SCHEMA_PROJECTION = {
"catalog": {
"id": "freight",
"label": "Freight desk",
"children": [
{
"id": "shipments",
"label": "Find and inspect shipments",
"object_kind": SHIPMENT_KIND,
"operations": ["object.search", "object.get"],
},
{
"id": "dispatch",
"label": "Dispatch and recover",
"keywords": ["carrier", "reschedule", "exception"],
"children": [
{
"id": "planning",
"object_kind": SHIPMENT_KIND,
"operations": [
"object.action:reschedule",
"object.action:assign_carrier",
],
},
{
"id": "exceptions",
"object_kind": EXCEPTION_KIND,
"operations": ["object.action:escalate_exception"],
},
],
},
],
},
"kinds": {
SHIPMENT_KIND: { ... },
EXCEPTION_KIND: { ... },
POD_KIND: { ... },
},
}
The hierarchy may be as deep as the domain needs. An agent can browse
schema_path="/dispatch/planning" or search the declaration:
object_schema(namespace="freight", query="change the carrier")
-> catalog_path: /dispatch/planning
-> object_kind: freight.shipment
-> schema_operation: object.action:assign_carrier
The caller expands that exact kind/operation pair before invoking it. The shared index contains labels, descriptions, keywords, kinds, and operation ids. It contains no shipments or other provider objects. Lexical matching is always available; semantic/hybrid capability search reports the effective mode when embeddings are unavailable.
The provider-owning bundle prepares that declaration index in shared storage. Its immutable generation identity covers both the catalog and the embedding profile: an unchanged load reuses the same timestamped generation; a catalog or embedding-profile change creates a new one. A successful newest loader retains the current generation and its immediate predecessor, then removes older file families. Consumers query the prepared catalog; they never build it themselves.
4. Name the use cases
Actions are the realm's bounded verbs — named use cases with declared payloads, never a shell:
"actions": {
"reschedule": {...}, # move the delivery window
"assign_carrier": {...}, # put a carrier on the shipment
"escalate_exception": {...}, # page the on-call desk
"attach_pod": {...}, # attach a proof-of-delivery file
}
They ride the generic grammar (object.action with an action
name), so every operator — the chat agent, an external agent over MCP, a UI drop — calls
them the same way. The provider dispatches the family operation, while authorization and
consent use the exact stable key, such as object.action.assign_carrier. A user
can grant one bounded effect without granting every action in the realm.
5. Guard it
Two honest shapes, depending on whose credentials do the work:
- Internal realm (freight runs on your own store): the door checks
delegated grants (
freight:read,freight:dispatch) per namespace and operation; inside, the realm authorizes with its own rules against the operator's identity. Declare no account claims — nothing is invented. - Provider-backed realm (operations act through a user's external account — the mail/slack shape): declare the connected-account requirements machine-readably, with per-operation differentiation only where it is real.
"connected_accounts": [{
"provider_id": "carrier_portal",
"connector_app_id": "acme",
"provider_label": "Acme Portal",
"claims": ["acme:read", "acme:book"],
"claims_by_operation": {
"object.search": ["acme:read"],
"object.action.assign_carrier": ["acme:book"],
},
"claim_labels": {"acme:read": "read bookings", "acme:book": "book carriers"},
}],
Provider-backed calls cross two independent gates. Delegated by KDCube decides whether this agent or external client may enter the KDCube resource and exact operation. Delegated to KDCube decides whether KDCube may use the user's connected provider account for the required claim. The delegated bearer never contains the provider token.
Consent is demand-driven: nothing is asked at turn start. The attempt that hits a missing claim returns the structured answer — reason, labeled candidates, an absolute Connection Hub deep link into the consent plan seeded with exactly those claims, a retry hint, and agent-facing instructions — while the turn keeps working.
6. Teach both readers
The schema teaches the agent. The presentation teaches the user — the capability picker renders it as the realm's service card: your entries grouped into Read / Create & update / Actions, your labels forming the group summaries, grammar tokens demoted to expandable details — and it renders nothing you didn't declare:
"presentation": {
"about": "Track shipments, handle exceptions, and dispatch carriers from your desk.",
"works_with": "Works with your freight desk's shipments and their documents.",
# provider-backed realms declare `third_party` instead:
# "Works with your carrier bookings through your connected Acme Portal account."
"operations": {
"object.search": {"label": "Search shipments", "description": "Search shipments by route, carrier, status, and overdue time."},
"object.get": {"label": "Read a shipment", "description": "Read one shipment with its exceptions and documents."},
},
"actions": {
"reschedule": {"label": "Reschedule a shipment", "description": "Move a shipment's delivery window."},
"attach_pod": {"label": "Attach proof of delivery", "description": "Attach a signed delivery document to a shipment."},
},
},
"object_kinds": OBJECT_KIND_DESCRIPTIONS,
Missing text here is a realm defect — the card shows the honest "This service hasn't described itself yet." rather than inventing copy. The user reads the card, understands what the realm does and touches, and narrows it per operation or action; a denied entry is rejected at dispatch, not hidden in UI.
7. Keep complete data reachable
Discovery results should stay lean, but compact output must not make authorized data unreachable. The provider declares how a client continues:
long collection page + next_cursor
large object or file metadata + short-lived signed KDCube URL
harness materializer object.get(response_mode=stream) -> stable turn snapshot
For an external MCP client, the signed URL keeps large content out of the tool result. A URL may stream an already hosted KDCube artifact, or it may be a live provider proxy. In the live case, KDCube verifies the signed ref and caller scope, resolves the user's current connected credential server-side, checks current consent, and fetches the provider data. The Google, Slack, or other provider token never reaches the client. The caller grant is checked when the URL is minted; the URL itself is the short-lived download capability, while connected-account consent is checked again on each use. The schema must state which kind of URL it returns.
A resident agent with the KDCube harness can pull the same provider ref into its turn
workspace. ReAct exposes this as react.pull; the
ported LangGraph example
uses the shared harness materialization adapter. The resulting local artifact is a stable
snapshot that preserves the canonical provider object_ref. External responses
remain provider-neutral; they never instruct Claude or another MCP client to call
ReAct-only tools.
File-taking actions need the same explicitness in the other direction. Declare one
file source in the action schema: an in-chat existing artifact uses a durable conversation
file_path, a turn-less client uses an upload handshake that returns
staged_ref, and a tiny generated file may carry
content_base64 with a filename. The provider implementation materializes those
bytes under current requester and grant authority. This is a named-service contract, not a
Slack special case.
Make the failure contract just as executable as the success contract. A missing source error should repeat
the accepted field names and the next valid call shape. For staged uploads, success means the client has
uploaded the bytes to the returned URL before using staged_ref. For inline images, validate both
base64 decoding and obvious format completeness before calling the provider, so a truncated PNG or JPEG fails
as an invalid inline file rather than becoming a corrupt provider object. Hosted MCP adapters unwrap a single
JSON text result, so this structured provider response is what the model actually receives.
8. Publish it from the owner app
The decorator carries the provider contract. The owner app makes the separate, explicit publication decision by contributing the provider instance to its registry:
@named_service_provider(
provider_id="freight.desk",
namespace=NAMESPACE,
refs=("freight:*",),
object_kinds=(SHIPMENT_KIND, EXCEPTION_KIND, POD_KIND),
search_scopes=FREIGHT_SEARCH_SCOPES,
operations=build_default_operations((TRANSPORT_LOCAL, TRANSPORT_API)),
label="Freight desk",
intro="Freight desk — shipments, exceptions, carriers. Search shipments, "
"read one, and run desk actions here.",
description="Freight-desk realm over the team's shipment store.",
metadata={ ...presentation, object_kinds, actions, connected_accounts... },
)
class FreightDeskProvider(NamedServiceProvider):
schema_projection_index = FREIGHT_SCHEMA_PROJECTION
class FreightEntrypoint(BaseEntrypointWithEconomics):
def _named_service_providers(self) -> list:
return [
*super()._named_service_providers(),
self._freight_provider(),
]
The base entrypoint publishes that complete current registry at app load. Providers that this same app published earlier but no longer contributes are withdrawn. Inheriting provider-capable code never publishes a realm by itself.
A consumer app declares the namespace and the operations each agent may use
(surfaces.as_consumer.agents.<id>.tools →
kind: named_service); the user's picker narrows within that grant.
Join a namespace another app already serves
Sometimes the realm you are building holds objects of a domain the platform already serves — the user would call them by the same name. A namespace accepts several providers, each publishing the part it serves: declare the existing namespace with your own provider id, declare only the operations you actually serve, and shape your ref patterns so they match only your ids. Routing sends every call to the provider whose declared ref pattern matches the ref most specifically.
The shipped example is linkedin. The built-in publishing provider owns
connected accounts and published posts, with URN post ids
(linkedin:<account>:post:urn:li:…) and the publish and comment actions.
A publications-store app joined the same namespace for its authored posts with store-shaped
ids that always carry a slash
(linkedin:<account>:post:<fold>/<slug>), serving search,
read, resolve, and its own open/download actions — and leaving account listing undeclared,
because the publisher already serves it. A URN never contains a slash, so on every
operation both providers declare, the id shape alone decides; neither provider can take the
other's calls.
Two consequences are yours to own. The platform accepts both publications without checking them against each other, so the disjointness is a test your app carries: for every shared operation, your patterns match your ids and reject the neighbor's. And the user still sees one service card for the namespace — the card merges what every provider declares, so your operations and labels appear beside the publisher's rather than replacing them.
Join when your objects are that domain's objects and the shapes can stay disjoint; mint a new namespace when they cannot.
9. Test it — both readers
- The agent reader: an operator who has never seen the domain opens the
schema root, browses a nested path or searches capabilities, expands one exact operation,
searches objects, follows a ref through
get, and performs one guardedaction— correctly, on the first try. - The capability index: bundle load prepares it in shared storage; an unchanged declaration and embedding profile reuse one generation, a change creates the next timestamped generation, current plus previous are retained, and a missing semantic backend returns an explicit lexical fallback.
- The human reader: a user who has never seen the domain opens the realm's service card and can say what it does, what it works with, and turn any operation or action off.
- The consent path (provider-backed realms): with zero consent, the first attempt returns the structured answer with the seeded deep link; after approval, the same call succeeds.
- The result path: through managed MCP, assert that one JSON text content block becomes the direct provider object seen by the hosted tool, including the exact error code and fields.
- The upload path: assert that a staged ref is unusable until bytes have been uploaded, and that obviously incomplete inline images fail before the external provider is called.
- The complete-data path: paginate a long collection; fetch one signed external delivery; pull the same ref through a harness agent; verify both return authorized complete data without putting provider credentials or large bytes in the model-facing result.
- The action boundary: grant one exact action, verify it succeeds, and verify a sibling action remains denied.
- The shared-namespace partition (when you joined an existing namespace): for every operation both providers declare, a ref of each shape routes to its own provider, and your disjointness test rejects the neighbor's shapes.
- Watch the seams: discovery reconciliation logs at app load, the
namespace roster in the agent's instructions, the picker's card, and — for external
agents — the same realm through the generic
named_servicesMCP gateway.
Documentation on GitHub
- Namespace service providers
- Ontologic tools
- Named-service app recipe
- Integration flow
- Discovery registry
- Per-user agent capabilities
- Delegated accounts
- Named services over MCP
- Google Sheets named service
- Named Services: The Interface Between Agents And App Realms
- Connect Your Named Services To Claude Code