This journal entry records what it actually took to make a KDCube named service — conversations (conv) — usable by Claude Code as an external agent over the managed MCP surface, and the concrete nuances we fixed once a real agent started calling it.

The important design decision is simple:

Claude Code connects to one generic MCP surface (named_services).
It works purely through object refs: search -> ref -> get.
It has no KDCube session and no ambient conversation.
Every ref it gets back must resolve on its own.
Binary files come back as a link — never base64 in the model's context.

The article Named Services: The Interface Between Agents And App Realms covers the design; this is the field-notes companion — what bit us and how each piece now behaves.

The surface and the connect

One app, kdcube-services@1-0, exposes a generic named_services MCP surface. The agent gets namespace-agnostic tools and picks the namespace per call:

named_services_list          discover namespaces
named_services_about         intro + search scopes for one namespace
named_services_capabilities  what this namespace supports right now
named_services_schema        object kinds, filters, scopes, grant hints
named_services_search        find objects -> refs
named_services_get           read one object (or many refs) by ref
named_services_call          generic operation (object.action, ...)

Connecting Claude Code is the delegated-consent flow: add the MCP URL as a connector, sign in to KDCube, approve the tools and grants.

https://<runtime>/api/integrations/bundles/<tenant>/<project>/
  kdcube-services@1-0/public/mcp/named_services

The consent, guard, and delegated credential are the Connection Hub series' job. This entry starts where the agent is already connected and asks: what should each call return so Claude can actually use it?

If Claude reaches consent but approval returns invalid_client / unknown client_id, the MCP surface is not the first suspect. Dynamic client registration and consent POST validation live in Connection Hub. Check proc logs for:

[connection-hub.oauth] authorize_consent rejected ...
[connection-hub.oauth] dynamic_client_missing ...
[connection-hub.oauth] consent params recovered from authorize referrer ...

A custom consent renderer should preserve the Connection Hub payload's hidden authorize fields (client_id, redirect_uri, scope, resource, state, PKCE) and POST approve/deny to form_action. Connection Hub can recover missing non-secret authorize fields from the same-origin authorize referrer, but the renderer contract is still to submit them.

The loop Claude runs

named_services_list                          -> mem, conv, task, cnv
named_services_schema  namespace=conv        -> object kinds, filters, scopes
named_services_search  namespace=conv        -> conv:turn / conv:fi refs
named_services_get     conv:conversation:<id> -> a lightweight timeline
named_services_get     conv:fi:<...>          -> the file (text inline / binary url)
THE RECALL LOOP — SEARCH · TIMELINE · FILE Claude Code external agent no ambient session the same ref grammar threads through every step 1 search conv query → conv:turn / conv:fi lean hits — refs, not bodies 2 get conv:conversation → a lightweight timeline emits conv:fi: refs 3 get conv:fi: → the file, by type text → inline binary → link One loop, three calls: search to a ref, read the timeline, get the file — every ref resolves on its own. Text inlines into the result; a binary comes back as a short-lived link Claude fetches over HTTP.
One loop, three calls: search to a ref, read the timeline, get the file — every ref resolves on its own. Text inlines; a binary comes back as a short-lived link Claude fetches over HTTP.

Everything below is a thing that loop got wrong on the first real run, and what it does now.

Where it bit us hardest: files over MCP

We asked Claude to fetch a chart the assistant had produced in a conversation. object.get on the conv:fi: ref returned the real 143 KB PNG — as ~190 KB of base64 inside the JSON result. Claude's client could not hold that in context: it offloaded the whole tool result to a file and ran Python to walk the JSON and base64-decode the bytes. It got the file, but the path was wrong.

An MCP tool result is JSON. Bytes can only ride inline as base64, and base64 lands in the model's context. That is the wrong boundary for bytes. So conv now answers object.get for a file by type:

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).
encoding = none    metadata only (too large, or no link configured).

The link is session-less and signed: a stateless HMAC token bound to the exact file and requester (file ref + user + conversation + tenant + project + a short expiry), verified by a public download route that re-materializes the bytes under the requester's own identity. Claude fetches it with a plain GET; the model never sees the bytes.

GET .../kdcube-services@1-0/public/conv_file_download?object_ref=...&download_token=...
  -> verify token (signature + expiry + exact file)
  -> materialize bytes under the token's identity
  -> stream them (Content-Disposition attachment)

Refs must stand on their own

The first cut emitted bare fi:turn_... refs. Inside the runtime those resolve against the ambient conversation; Claude has no ambient conversation, so it passed one back and got not_found.

The fix is to make every emitted ref carry its own scope:

before:  fi:turn_<id>.outputs/chart.png                 (needs ambient state)
after:   conv:fi:conv_<id>.turn_<id>.outputs/chart.png   (resolves on its own)

Now a ref round-trips: whatever search or object.get hands back is valid input to the next object.get.

Lean, honest results

The early search results were verbose and unhelpful: empty snippets, a full single-object envelope on every hit, and titles set to the turn id. An agent cannot choose a hit from that. The search now returns lean, actionable rows:

{ ref: "conv:turn:<id>",
  title: "<first meaningful snippet text>",     # never blank, never the raw id
  body: { conversation_id, turn_id, snippets: [ {role, path, text} ] },
  score }

Three rules came out of this:

Default to the useful scope.
  conv search defaults scope=user (recall across the user's conversations).

Expose only a knob the agent can set sanely.
  no min_score filter — the hybrid score is not normalized 0..1, so a threshold
  would be a trap; ordinal/rank_score are off the agent-facing schema.

Capabilities reflect what is wired.
  search/list/get report true only when the backing service is configured.

The conversation timeline

object.get conv:conversation:<id> is not a raw dump. It fetches the rich per-turn record and distills a compact, time-ordered timeline:

{ conversation_id, user_id, title, turn_count,
  turns: [ { turn_id, events: [ ... ] } ] }

events: user.message | user.attachment | assistant.thinking |
        assistant.message | assistant.file | artifacts | sources

Produced files and uploaded attachments surface as conv:fi: events with conversation-scoped refs — which is exactly what Claude hands to the next object.get. Heavy bodies are dropped: sources collapse to sid/title/url, artifacts to name/title/format.

Config: the secret lives in the descriptor

The download link is signed, so it needs a signing secret. It comes from one place — the bundle secret descriptor — with no environment variables and no fallback chain. One key:

# bundles.secrets.yaml
bundles:
  items:
    - id: kdcube-services@1-0
      secrets:
        conversations:
          file_download_secret: <hex>

Mint (on the MCP call) and verify (on the download hit) resolve the same descriptor secret, so it is stable across worker processes. Absent secret means no link — binaries then fall back to inline/metadata, and the download route replies download_not_configured.

Two scenarios

Recall a discussion and open the chart the user made:

1. search  namespace=conv, query="the revenue chart", scope=user
2. get     conv:conversation:<id>   -> timeline; an assistant.file event carries
                                        conv:fi:conv_<id>.turn_<id>.outputs/chart.png
3. get     that conv:fi: ref         -> { encoding: "url", url, expires_at }
4. Claude GETs the url over HTTP     -> raw PNG, never in context

Read a spreadsheet the user uploaded last week:

1. search  namespace=conv, targets=[attachment], from/to = last week
2. get     the conv:fi: attachment ref
     -> a .csv/.md comes back encoding=text (inline);
        an .xlsx comes back encoding=url (fetch the bytes to analyze)

Logs to look for

[conversation.named_service.file] ref=conv:fi:... fi=fi:... conversation_id=... user_id=...
[conversation.files] materialize ref=... physical=... conversation_id=... browser_conversation_id=...
[conversation.files] rehost ref=... rehosted=... files_found=... missing=... errors=...

Download route outcomes:

200                           bytes streamed (Content-Disposition attachment)
403 download_token_rejected   bad signature / expired / wrong file
400 download_request_invalid  missing ref or token
503 download_not_configured   no conversations.file_download_secret in the descriptor

A binary that comes back encoding=none with no error usually means the secret is unset or the public origin was unknown at mint time — check the descriptor and the request base.

Related publications

Named Services: The Interface Between Agents And App Realms — the design piece; see its section Files cross the boundary as links, not bytes.
Delegate A KDCube Service To An External Client — the connect and consent journey behind the guarded door.
KDCube Journal · 02.07.2026