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
PreludeStoreauthoring 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. Seedocs/plans/archive/capability-prelude-discovery.mdfor 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)}))| Key | Meaning |
|---|---|
:ok | true on success, false on a recoverable failure |
:value | the result payload when :ok |
:reason | the 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):
| Visibility | In the prompt inventory? | Discoverable? |
|---|---|---|
:prompt | yes (compact entry) | yes |
:discoverable | no | yes |
(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 grantedtools:map. The synthetic"call"of(tool/call ...)is not promoted totool:call— literal upstream calls are already covered precisely by theirupstream: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 norequiresand 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
requiresvalidation bounds which operations a prelude may reach.PtcRunner.Upstream.Eval.run_subagent/3also installs a default continuation guard: after an observed upstreamtool/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-suppliedcontinuation_guardoverrides 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 + MCPExact 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 program — PtcRunner.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 SubAgent — PtcRunner.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/3validates a prelude'srequiresfail-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_effectsand sanitized%{matched_calls: [...]}details. Passcontinuation_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.compiledPreludeStore 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
:discoverableto keep the prompt inventory small while staying reachable via(apropos ...)/(ns-publics ...). - Declare
requires/provider-refexplicitly when the backing call isn't a simple literal, so attach-time validation still protects you.
11. Troubleshooting
| Symptom | Cause & fix |
|---|---|
unknown namespace crm/... at runtime | The 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 namespace | Agent 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 once | Two (ns crm ...) directives in one file. Merge them. |
invalid visibility / :requires must be a list of strings / duplicate ... ref | Bad export metadata — compilation fails fast. Fix the metadata. |
prelude evaluation exceeded sandbox limits | A (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 prompt | Check its visibility — :discoverable exports are intentionally omitted from the inventory (still discoverable). |
See also
- PTC-Lisp Specification §9.9 — Capability Prelude — language-level rules.
- SubAgent Advanced — namespaces, the
user/namespace, and prelude attachment in context. - Function Reference —
doc,dir,meta,apropos,ns-publics,all-ns,ns-name.