Capability Preludes — Authoring & Deploying Guide

Copy Markdown View Source

A capability prelude lets a deployment expose curated, Lisp-facing APIs to agents without hard-coding each one into the library or stuffing full source into the prompt. You write a small PTC-Lisp file that declares protected namespaces (e.g. crm) with public exports; agents call and discover those exports like any built-in, while private helpers stay hidden.

This guide is the practical how-to: building a prelude, wiring it into a run, and the decisions you make along the way. For the language-level rules see §9.9 Capability Prelude in the specification; for the discovery forms see function-reference.md.

V1 scope. A prelude is stateless: it defines namespaces, constants, functions, docstrings, and metadata, but holds no hidden mutable state. There is no generic capability catalog yet. The optional PreludeStore authoring surface records new source versions, but it is host-gated, compile-checked, volatile in-memory state; stored preludes still do not mint credentials or tool authority. See docs/plans/archive/capability-prelude-discovery.md for the deferred catalog/profile work.


1. Quick start (60 seconds)

Compile a prelude source string into an artifact, then attach it to a SubAgent:

prelude_source = """
(ns crm
  "CRM helpers."
  {:visibility :prompt})

(defn get-user
  "Return a CRM user by id."
  [id]
  (tool/call {:server "crm" :tool "get_user" :args {:id id}}))
"""

{:ok, prelude} = PtcRunner.Lisp.Prelude.Compiler.compile(prelude_source)

agent =
  PtcRunner.SubAgent.new(
    prompt: "Look up the requested user",
    runtime_prelude: prelude,
    llm: llm
  )

The agent's program can now call (crm/get-user data/user-id), branch on the result, and discover the export with (ns-publics 'crm) / (doc 'crm/get-user).

Compile once, attach anywhere — the same artifact works across direct execution, SubAgent execution, and the REPL (§7).


2. Anatomy of a prelude file

(ns crm
  "CRM helpers."              ; optional namespace docstring
  {:visibility :prompt})      ; optional namespace metadata (defaults below)

(def page-size 50)            ; a constant export

(defn- normalize-id           ; PRIVATE helper (defn-): not user-visible
  "Trim and tag a raw id."
  [raw]
  (str "norm:" raw))

(defn get-user                ; PUBLIC export (defn)
  "Return a CRM user by id."
  [id]
  (tool/call {:server "crm" :tool "get_user" :args {:id (normalize-id id)}}))
  • (ns name "doc" {meta}) is a compiler directive — it declares a protected namespace. Declare each namespace exactly once per file; reopening it is rejected.
  • (defn name "doc" [args] body) defines a public export.
  • (defn- ...) defines a private helper. Public exports may call it; user code can never resolve or discover it by qualified symbol.
  • (def name value) defines a constant export. Reference it as a value (crm/page-size); a zero-arg call (crm/page-size) also yields the value.
  • You may declare several namespaces in one file.

Reserved namespaces

A prelude cannot declare the host-reserved namespaces tool, data, budget, or ptc.core — compilation fails. These stay under host control.


3. Calling exports from agent code

Prelude exports wrap the existing tool surfaces unchanged, so they are recoverable-by-default. A wrapper around (tool/call ...) returns the same result map a direct call returns, and the agent branches on it:

(def res (crm/get-user "u_123"))
(if (res :ok)
  (return {:user (res :value)})
  (return {:error (res :reason)}))
KeyMeaning
:oktrue on success, false on a recoverable failure
:valuethe result payload when :ok
:reasonthe failure reason when not :ok

Abort-on-error helpers

If you want a helper that aborts the whole program on failure (rather than returning a recoverable map), call fail yourself and name it with a ! suffix so the behavior is visible at the call site:

(defn get-user!
  "Return a CRM user, or abort the program."
  [id]
  (let [res (get-user id)]      ; call the sibling by its BARE name
    (if (res :ok) (res :value) (fail {:reason (res :reason)}))))

Keep fail for intentional aborts; do not make every wrapper abort by default.

Calling siblings. Within a prelude namespace, call other exports/helpers by their bare name (get-user), not qualified (crm/get-user). Qualified self-references are rejected at compile time — qualified refs are for user code calling the prelude, not for the prelude calling itself.


4. Public, private, and prompt visibility

Every public export has a visibility, set on the export (or defaulted from the namespace, then the global default :prompt):

VisibilityIn the prompt inventory?Discoverable?
:promptyes (compact entry)yes
:discoverablenoyes
(ns crm "CRM." {:visibility :prompt})   ; namespace default

(defn get-user "..." [id] ...)          ; inherits :prompt

(defn list-users
  "List CRM users."
  {:visibility :discoverable}           ; per-export override
  []
  (tool/call {:server "crm" :tool "list_users" :args {}}))

Prompt-visible exports are summarized in a compact, deployment-defined prompt inventory assembled dynamically — the core prompt templates stay domain-blind (they never mention crm). :discoverable exports are omitted from the inventory but still reachable via the discovery forms (§6).

Visibility can only be narrowed by host policy. Prelude metadata is advisory and can never broaden what is exposed.


5. Backing tools and upstream requires

When an export wraps a literal upstream call, the compiler infers its backing operation id and records it under requires:

(defn get-user [id]
  (tool/call {:server "crm" :tool "get_user" :args {:id id}}))
;; => provider_ref "upstream:crm/get_user", requires ["upstream:crm/get_user"]

Two backing id shapes are inferred and validated:

  • "upstream:<server>/<tool>" — a literal (tool/call {:server "x" :tool "y" ...}). Validated against the selected upstream runtime.
  • "tool:<name>" — a typed tool call (tool/<name> ...) (a host-bound capability). Validated against the run's granted tools: map. The synthetic "call" of (tool/call ...) is not promoted to tool:call — literal upstream calls are already covered precisely by their upstream: id.
  • An export that reaches a backing through a private helper inherits the requirement transitively (it still fails closed at attach time).
  • A dynamic (tool/call {:server server :tool tool ...}) whose server/tool are runtime values cannot be inferred — it carries no requires and must be declared explicitly if you want a fail-closed guarantee.

You can also declare backing metadata explicitly. requires is the union of inferred and explicit ids — explicit can add requirements but never drop an inferred (fail-closed) one. provider-ref and effect keep explicit-override semantics:

(defn search
  "Search users."
  {:provider-ref "upstream:crm/search" :effect :read
   :requires ["upstream:crm/search"]}
  [query]
  (tool/call {:server "crm" :tool "search" :args {:q query}}))

Metadata uses kebab-case keywords (:provider-ref); they are normalized at the host boundary. Malformed :requires (non-string entries) fails compilation rather than being silently dropped; an unrecognized id shape (neither upstream: nor tool:) fails closed at attach time.

Attach-time validation

When you attach a prelude with a selected upstream runtime (§7), each public export's requires is checked against that runtime before any user code runs. If a required upstream operation is not configured/granted, attachment fails fast with :prelude_attach_failed — so a backing that is missing at attach time can never cause a partial run with side effects.

This holds at the initial attach across every execution surface: direct Lisp.run, the multi-turn SubAgent loop's first turn, and the single-shot fast path. On the multi-turn path each turn re-validates, and a mid-run :prelude_attach_failed is a hard stop, never a recoverable retry turn — but note the scope: under the default :live catalog a backing can disappear after an earlier turn has already executed a side effect, so the hard stop then guarantees only that no further turn runs, not that the whole run was side-effect-free. An unconditional cross-turn guarantee requires a frozen-for-the-run catalog snapshot (a planned optimization); until then the honest scope is fail-closed before any side-effecting turn. The validation covers the agent given the runtime: a child SubAgent invoked via as_tool is upstream-blind — it does not inherit the parent's runtime, so its own requires-backed prelude is only validated if that child is itself run through its own upstream bridge/runtime.

Default side-effect guard. Fail-closed requires validation bounds which operations a prelude may reach. PtcRunner.Upstream.Eval.run_subagent/3 also installs a default continuation guard: after an observed upstream tool/call, read-classified calls may continue, while write-classified or unknown-effect calls stop before the next LLM turn with :partial_side_effects. The failure details contain sanitized %{matched_calls: [%{server, tool, effect}, ...]} entries only — never upstream args or results. A host-supplied continuation_guard overrides this default completely.

The prelude does not define upstream endpoints or credentials. It only wraps operations the host has already configured. Credentials live in host/deployment config and never appear in the artifact, prompts, or traces.


6. Discovering exports from agent code

Agents discover prelude exports with the same forms used for built-ins and MCP tools. Namespace refs accept a quoted symbol ('crm) or a string ("crm"):

(all-ns)                 ; sorted namespace names, incl. attached prelude ns
(ns-name 'crm)           ; => "crm"
(ns-publics 'crm)        ; map of public symbol => compact metadata
(dir 'crm)               ; member lines (honors {:limit :offset})
(doc 'crm/get-user)      ; docstring
(meta 'crm/get-user)     ; structured metadata (arity, effect, provider, ...)
(source 'crm/get-user)   ; prints the rendered defining form, returns nil
(apropos "user")         ; fuzzy search across prelude + local + MCP

Exact prelude-export refs resolve through the prelude first; private helpers have no export record, so they never appear in doc/meta/ns-publics/ apropos and are not callable by qualified symbol. The one exception is source: a private helper a public export reaches is readable via (source 'crm/<helper>) (read-only — it shows the body, it does not make the helper callable). source is prelude-only: an unknown ref prints "no source available" and returns nil.


7. Attaching a prelude to a run

The same compiled artifact attaches through four seams.

Direct execution (PtcRunner.Lisp.run/2) — pass a compiled artifact or source (compiled before user-code analysis):

PtcRunner.Lisp.run(program, prelude: prelude)
# or, to validate `requires` against a selected upstream runtime:
PtcRunner.Lisp.run(program, prelude: prelude, runtime: upstream_runtime)

SubAgent — the runtime_prelude: field on %PtcRunner.SubAgent.Definition{} (via SubAgent.new/1). This works across every SubAgent execution path (multi-turn loop, single-shot, and compiled agents):

%PtcRunner.SubAgent.Definition{runtime_prelude: prelude}

Upstream-backed single programPtcRunner.Upstream.Eval.run_lisp/3 runs one Lisp program against a selected upstream runtime and forwards that runtime into the attach path automatically, so requires are validated:

PtcRunner.Upstream.Eval.run_lisp(runtime, program, prelude: prelude)

Upstream-backed multi-turn SubAgentPtcRunner.Upstream.Eval.run_subagent/3 is the analogue of run_lisp/3 for a multi-turn agent. It owns a single RunContext for the whole run, enriches the agent with the upstream-call tool before prompt generation, and threads the runtime into every turn so the prelude requires validate fail-closed per turn:

PtcRunner.Upstream.Eval.run_subagent(runtime, agent, llm: llm, context: ctx)

The distinction: run_lisp/3 runs a single program; run_subagent/3 runs a multi-turn agent. Both forward the same runtime into the attach path, so in either case requires are validated against the selected upstream.

Default side-effect guard. run_subagent/3 validates a prelude's requires fail-closed per turn and installs a default side-effect continuation guard. Read-classified upstream calls may continue; write or unknown calls stop before the next turn with :partial_side_effects and sanitized %{matched_calls: [...]} details. Pass continuation_guard: to replace this default with host-owned policy. (See §5.)

If no upstream runtime is selected (e.g. a direct Lisp.run with a stub tools: map), upstream: requirements are skipped (there is no runtime to check; the granted (tool/call ...) closure plus the pre-execution tool guard still apply). tool: requirements are always validated against the granted tools: map and fail closed when ungranted — so a host-bound capability prelude (like the log/ introspection prelude) is guarded whether or not a runtime is configured.


8. Iterating with the REPL

The REPL uses the same compiler, protected-namespace tables, export records, and prompt-inventory renderer as SubAgent execution — it is not a parallel implementation:

# Attach a prelude file and open the REPL
mix ptc.repl --prelude crm.clj            # alias: -p crm.clj

# Print the prompt inventory the agent would see
mix ptc.repl --prelude crm.clj --show-prompt-inventory

# Evaluate a program against the attached prelude
mix ptc.repl --prelude crm.clj -e "(ns-publics 'crm)"

--prelude is separate from -l/--load (which loads ordinary user code). --help is side-effect-free — it never loads the prelude.

Hosts that already have multiple prelude sources can compose them in process without a store:

PtcRunner.Lisp.run(program,
  prelude: [
    %{id: "log", source: log_source, origin: :memory},
    %{id: "paged", source: paged_source, origin: {:file, "priv/paged.clj"}}
  ]
)

Selections are concatenated in explicit order, duplicate namespaces fail closed, and the aggregate is compiled once into the same %PtcRunner.Lisp.Prelude{} artifact used by the single-prelude path.

For in-process prelude iteration, hosts can use the volatile core store:

{:ok, store} = PtcRunner.PreludeStore.new()
{:ok, v1} = PtcRunner.PreludeStore.write(store, "paged", paged_source_v1)
{:ok, v2} = PtcRunner.PreludeStore.write(store, "paged", paged_source_v2)

{:ok, candidate} =
  PtcRunner.PreludeStore.read(store, %{id: "paged", version: v2.version})

candidate.compiled

PreludeStore is compile-on-write with bounded in-memory retention. Every successful write/4 creates a new monotonic version and makes it the current/default version for that id. A bare id reads the current/default version, "paged@7" reads an explicit retained version, and %{id:, version:, checksum:} adds a checksum assertion. Older superseded versions may be pruned once the per-id retention window is full. An explicit version selected with set_default/4 is retained alongside that latest-version window, even if later writes move the default back to the newest version.

The store keeps explicit default selection separate from newest-version tracking:

{:ok, _selection} =
  PtcRunner.PreludeStore.set_default(store, "paged", v1.version, %{
    "reason" => "verifier preferred v1"
  })

{:ok, current} = PtcRunner.PreludeStore.read(store, "paged")
current.version
# => 1

[%{current_version: 1, latest_version: 2}] = PtcRunner.PreludeStore.list(store)
{:ok, history} = PtcRunner.PreludeStore.history(store, "paged")

Use history/2 to inspect retained versions for one id. Use set_default/4 only for an explicit host-owned promotion or rollback after verification; it does not delete later versions, but normal retention pruning may still remove superseded rows that are neither explicitly selected nor inside the retained latest-version window.

Stored source and metadata are untrusted prompt surfaces. Use PtcRunner.PreludeCandidate.public_view/1 for model-facing projections. Model-facing store tools keep source bounded and filter metadata to documented public scalar keys; private backing-tool ledgers summarize source args and filter metadata before traces or step.tool_calls retain them.

Editor sessions can attach the host-shipped prelude/ wrapper over private store tools. In the MCP server this is available only when the operator starts with --sessions-allow-prelude-write, a prelude store is configured, and the session starts with mode: "write_capable". Read-only sessions do not see the prelude/ namespace or the private prelude_store_* backing tools. The model-visible API includes:

(prelude/list)
(prelude/history "paged")
(prelude/read "paged@2")
(prelude/source "paged")
(prelude/write {:id "paged" :source new-source :metadata {:reason "add profile"}})
(prelude/set-default {:id "paged" :version 1 :metadata {:reason "rollback"}})

prelude/set-default accepts an optional checksum:

(prelude/set-default
  {:id "paged"
   :version 1
   :checksum "..."
   :metadata {:reason "verified candidate"}})

PtcRunner.Session can resolve store refs once at session start and freeze the compiled bundle for that session:

session = PtcRunner.Session.new(prelude_store: store, preludes: ["paged"])
{{:ok, step}, session} = PtcRunner.Session.eval(session, "(paged/inspect)")

9. Traceability

When a prelude is attached, step.prelude_trace carries a credential-free summary so a run's capability environment is reproducible from traces:

  • prelude source hash and compiled-artifact hash,
  • selected protected namespaces,
  • component ids, hashes, namespaces, and origins when the prelude was composed from multiple selected sources,
  • the public export records (ref, namespace, symbol, arity, params, visibility, effect, provider, requires).

No closures, no private env, and no secrets appear in it.


10. Authoring conventions

  • One namespace, one declaration. Put all of a namespace's defs under a single (ns ...) directive.
  • Curate Lisp-facing names in kebab-case (get-user), even when the backing tool uses snake_case (get_user).
  • Keep wrappers recoverable; reserve !-suffixed helpers for explicit aborts.
  • Hide implementation details behind defn-; expose only what agents should call.
  • Mark rarely-used exports :discoverable to keep the prompt inventory small while staying reachable via (apropos ...) / (ns-publics ...).
  • Declare requires/provider-ref explicitly when the backing call isn't a simple literal, so attach-time validation still protects you.

11. Troubleshooting

SymptomCause & fix
unknown namespace crm/... at runtimeThe prelude wasn't attached on this execution path. Confirm prelude: / runtime_prelude: is set; for SubAgents this covers loop, single-shot, and compiled agents.
prelude attach failed: ... upstream:crm/get_user (:prelude_attach_failed)A public export requires an upstream operation the selected runtime doesn't provide. Configure/grant it, or attach without a :runtime to skip the upstream check.
prelude attach failed: ... requires granted tool \log_sessions`(:prelude_attach_failed`)A public export requires a tool:<name> the host did not grant. Add the closure to the run's tools: map (these are validated even with no :runtime).
cannot redefine crm/get-user / crm is a protected namespaceAgent code tried to def/defn into a protected namespace or over an export. Protected names are immutable from user code.
namespace 'crm' is declared more than onceTwo (ns crm ...) directives in one file. Merge them.
invalid visibility / :requires must be a list of strings / duplicate ... refBad export metadata — compilation fails fast. Fix the metadata.
prelude evaluation exceeded sandbox limitsA (def ...) constant's value is too expensive/large; constants are evaluated under a bounded sandbox at compile time. Use a cheaper constant.
A :prompt export doesn't show in the promptCheck its visibility — :discoverable exports are intentionally omitted from the inventory (still discoverable).

See also