@mcmaki/loom
v0.1.15
Published
Loom — A manifest-driven agent meta-harness
Maintainers
Readme
Loom
A capability-secure, manifest-driven agent runtime.
[!NOTE] Humans, start here:
Loom is a package manager and runtime for agent harnesses. Define
your agent's features and capabilities in an agent.toml manifest,
install any skills as you need, check what it can do via loom audit,
then prompt it via loom prompt:
[agent]
name = "example-agent"
system_prompt = "You are a helpful assistant."
[harness]
provider = "anthropic" # This harness will inform Loom it needs an ANTHROPIC_API_KEY
model = "claude-sonnet-4-5"
# Context management as composable layers.
[session]
layers = ["skills", "compacting", "in-memory"]
# Tools carry their own capabilities.
# Each builtin is sandboxed to exactly what you grant.
[tools]
read_file = { capabilities = { paths = ["./"] } }
write_file = { capabilities = { paths = ["./"] } }
edit_file = { capabilities = { paths = ["./"] } }
bash = { capabilities = [
{ commands = "*", paths = ["./"] }, # any command: this dir, no network
{ commands = ["gcalcli"], network = "*" }, # gcalcli alone may reach the network
] }Skills plug into the capability system. A skill is a folder with a SKILL.md
that can declare the tools and grants it needs — Loom activates it only
if its request fits what your agent.toml allows:
---
name: calendar
description: Read and add Google Calendar events with gcalcli.
metadata:
loom.tools: |
bash = { capabilities = { commands = ["gcalcli"], network = "*" } }
---
Use `gcalcli agenda`...Install
Install the CLI globally (requires Node ≥ 20):
npm install -g @mcmaki/loomOr run it without installing:
npx @mcmaki/loom run agent.tomlTo use Loom as a library in your own project:
npm install @mcmaki/loomThen point it at your manifest:
ANTHROPIC_API_KEY=... loom run agent.tomlStatus: early. I've burned a lot of tokens on this project, and the provider APIs are likely to be stable. However, as I discover new use cases, the APIs will likely expand. This project has been entirely vibe coded, so don't trust it with your production data just yet :)
Why Loom
Loom is a package manager for agents, that lets you spin up new agents from reusable components. It's intended for systems that need lots of micro-agents working together, with little oversight but a clear security posture. Want to build a swarm of agents to expand your openclaw's reach? Need to run an agent in CI, that talks to the network, yet never have to worry about prompt injection? Do you just want to build a memory system and not have to worry about everything that goes into building an Agent harness? Then Loom is for you.
Loom is based on Anthropic's Scaling Managed Agents: Decoupling the brain from the hands, with some adjustments to provide richer APIs:
- A harness, is responsible for acquiring tokens and dispatching tools. Loom ships with an Anthropic and OpenAI harness out of the box, but you can easily write your own for any other model provider. Have a local deepseek install? Want to talk to OpenRouter? Implement a
Harness. - A session owns your context window. Its job is to receive content updates from the harness, and turn them into a context window for the next invocation. Sessions are arranged into layers. Each layer can provide its own additions to the system prompt, tools, and processing of the messages it's seen. Loom will automatically call each one in turn when generating a context window or pushing a message. Loom uses these to implement core agent behavior as installable libraries.
- Tools are the foundation of any agent. They are the agent's only sense organs, as well as its only way to interact with the world. Loom allows you to configure tools individually but also provides a way to control tools with capabilities.
- Capabilities are a contextual description of what a tool may do. For example, a
read_filetool has capabilities for describing which paths it can read, while asend_discord_dmtool has capabilities for describing which users it can send DMs to. Capabilities are closed, no other mechanism can add capabilities to a manifest. - A provider, is a library that supplies any of the above components. This could be a git repo, an npm package, or a local directory.
- Loom has first class support for ACP, and each component can implement their own parts of the protocol.
Together, these pieces let you define the dependencies and configuration for a single agentic turn. To do more than one turn, you need to build a client that calls loom multiple times.
The client
Loom ships with a simple, no-frills CLI for debugging or interacting with an agent:
$ loom install <agent.toml>to resolve and install a harness from a manifest.$ loom prompt <agent.toml> [text]to send a prompt to a harness and run it for a single turn$ loom run <agent.toml>to run a simple client for interacting with the agent instantiated by the manifest$ loom audit <agent.toml>to see the full list of every component that will be used to run a given agent.
Loom is intended to be embedded in a larger agent system, called a "client", that orchestrates the loom invocations. This could be a discord bot, a multi agent system, a coding CLI, or anything else that needs to safely build and invoke an agent turn.
Security Model
Loom uses a capability-based security model, where its features and its security properties are one and the same. In loom, all agent security flows from the tools. These are the only mechanism the agent has for interacting with the world and so are the best location from which to define security boundaries. Loom cannot, and does not attempt to, protect you from supply chain attacks. Think of using loom as similar to installing a library or a CLI tool. Tool implementations have full access to your system, and are trusted to be responsible and honest in their interactions. Loom can only protect you from misbehaving agents, not misbehaving humans.
Loom uses capability security to both define and control which resources each tool has access to before it's been instantiated. Each tool uses a contextually appropriate capability scheme, defined and enforced by the tool itself. For example, Loom's bash tool wraps each invocation in it's own sandbox. However, for a read_discord_messages tool, a sandbox is a distraction. The tool implicitly needs access to a network, and its capabilities are better described in terms of user or channel IDs. By defining capabilities at the tool level, both of these tools can coexist in the same agent harness without contradiction.
Capabilites are a positive declaration of access. If it's not granted in the capability definition, the tool will not have access to the resource. While simple, this approach is powerful for defining fine-grained access control without needing to write complex permission logic. We use this feature extensively to manage the complexity of the tools provided by the MCP spec. We can filter the tools exposed to a useful subset, partially apply tool arguments so agents can only provide the arguments they need to, and render all of these policies visible by the capability grant section. However, per-tool capabilities on their own are not sufficient for complex permission logic.
So far we've been talking about composition across tools, but for composition within tools we have another feature: capability sets. These allow you to apply multiple capabilities to a single tool, as long as the tool is able to support them. Our bash tool uses this extensively to provide a flexible and fine-grained sandboxes via the commands capability. A simple { commands = "*" } grant gives you full access to the shell without any network access, but if you pair that with a { commands = [ "gcalcli" ], network = "*" } grant, the bash tool will only allow gcalcli to access the network. It does this by shadowing the gcalcli binary with a shim that connects to a socket, leading to a broker process outside of the sandbox. That broker process has a whitelist of allowed commands (driven by the commands capability set), and it spawns a second sandbox, just for the capabilities granted to the gcalcli command. The broker then hands whatever results it gets back to the shim, letting the in-sandbox process continue as if the command had completed locally. Because the bash tool supports capability sets, we can define powerful primitives for running commands, letting you safely compose agent features without worrying about what they can get up to. We use this to support the agent skills spec in a safe and flexible way. Check out the skills agent example for more.
Capabilities are always tied to the tools that define them. When adding tools to your Agent.toml, you specify the capabilities you want for each tool, as part of the tool's configuration. However, the tools you define aren't the whole picture. To be useful, agents must compose tools from multiple different sources together, such as memory tools tied to your session storage, or tools implied by a specific skill. As such, the Agent.toml contains an additional [capabilities] section, that lets you control the overall capabilities that your agent is allowed to access, in terms of the tools added to your manifest by all of your dependencies. This is a strict ceiling. As long as you trust your tool implementations, loom will never let you run an agent with capabilities not covered by your [capabilities] section.
If you don't include any [tools] or [capabilities] section, Loom will provide the same tools as Pi: read_file, write_file, edit_file, and bash, each scoped to the current working directory. To see the full list of providers, harnesses, sessions, tools, capabilities, and secrets that an agent manifest will use, run loom audit <agent.toml>.
Taken together, Loom's capability security model gives you leverage over the complexity of the AI ecosystem. You can safely install skills from anywhere on the internet, use MCP tools without blowing up context windows, and audit the whole security surface with a single command. All within a pluggable, modular architecture that lets you swap in new features as you see fit. The security model is the feature set.
Building a provider
Providers do everything interesting in Loom. Beyond implementing their own features, providers are responsible for accurately and honestly reporting what their dependencies are. If a harness or tool needs an API key, use your component's secret field to get it.
IMPORTANT: A tool's containsGrant and mergeGrants methods define its capability algebra. Loom provides a simple default, but many tools will want to provide a custom implementation for their domain. The soundness of these two methods rests on them forming a lattice, containsGrant a partial order and mergeGrants its least upper bound. Loom cannot provide it's capability checks if these methods are incorrect. As such, Loom property-checks these two methods on every agent boot, and uses the sampleGrant method to generate random values that conform to your tool's grant schema. loom audit will highlight if this sample method isn't implemented. Loom will refuse to run if the property check does not pass.
Tools and sessions can also define functional dependencies that are automatically included when those tools and sessions are used. For example, building an RLM agent on Loom requires your session to provide tools for the agent to configure the context window of its sub-agents. Similarly, a spawn-subagent tool might require its own configuration, such as its own system prompt and a subset of tools. Harnesses also provide their own tools, e.g. a web_search tool, but these tools are not automatically included in the agent's manifest.
Subagents are a special case of functional dependencies. Generally, if your component wants to use an LLM for its features, it can use the harness directly. But if you want to spawn a small research subagent, for example, use the subagents field in your component's type. Loom will ensure that all of its dependencies (secrets, packages, etc.) are resolved and available to your tool. Simply use Loom's spawnSubagent method to create your subagent by name.
Learning Loom
Claude has written a lot of documentation, but the main place to learn the entry points and common usage is the examples/ directory. I'd recommend starting there, before diving into the rest of the codebase. Loom has a lot of basic application features that provider authors might want to use, secret resolution, automatic storage directory, etc. But at the end of the day, Loom is only as useful as the providers that are built into it.
Docs
[!NOTE] Everything below this message is written by an LLM, intended for agents
Building from source
To hack on Loom itself or run the examples from a checkout (rather than the published package — see Install above):
git clone https://github.com/mikayla-maki/loom.git
cd loom
npm install
npm run build
node dist/cli/main.js helpRequires Node 20 or newer.
Quick start
examples/minimal-agent/ is a single agent.toml — the built-in
file/shell tools scoped to the current directory, behind an
Anthropic harness. No provider package, no build step. Audit it
first:
loom audit examples/minimal-agent/agent.tomlloom audit prints the resolved capability tree — every provider,
every tool, every grant — without ever calling the model. Skim it
to see exactly what the agent can do. Then run it:
ANTHROPIC_API_KEY=... loom run examples/minimal-agent/agent.tomlRead that manifest top to bottom and you understand the agent; copy
it and adjust to your needs. When you want persistent recall
across sessions, look at examples/full-agent/ — the same shape
plus a notes provider: tell it to remember something, quit with
/q, restart, and ask what it knows. The Examples section below
catalogs the rest.
Examples
Everything in examples/ is real, runnable, and auditable with
loom audit <dir>/agent.toml. Six self-contained projects, each
with its own README.md:
| Directory | What it demonstrates |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| examples/minimal-agent/ | Start here. The smallest useful agent: built-in file/shell tools scoped to the cwd, a two-layer session, capability grants — one agent.toml, no provider, no build step. |
| examples/full-agent/ | The declarative shape, fully loaded. A notes-taking assistant with persistent recall: a 3-layer session (compacting → notes → in-memory), built-in tools, harness-exposed web search, and a local provider reference. |
| examples/sdk-agent/ | The imperative SDK shape. The same agent as full-agent/, built in code. Demonstrates the heterogeneous session-array form (session: [compactor, "notes", "in-memory"]) — mix a hand-built CompactingSession instance with named layers the runtime resolves. The instance is what /compact and /tokens REPL commands reach into. Run with npx tsx examples/sdk-agent/agent.ts. |
| examples/mcp-agent/ | An MCP-driven agent, paired with a stand-alone example MCP server. End-to-end tour of Loom's mcp-server meta-provider: rename, narrow, pre-bind, secret-inject. |
| examples/skills-agent/ | Agent Skills as contributed tool groups. A five-skill tour, one skill per tier: pure instructions (authority-free, activated via read_skill), a frontmatter loom.tools bash row, a loom.toml sidecar declaring a renamed instance, a skill shipping its own MCP server via loom.providers (accepted by the instance-name echo_note = "*" consent line in agent.toml), and a deliberately rejected skill so loom audit shows the fail-soft verdict with its paste-ready remediation. |
| examples/notes-provider/ | A complete working provider package. Contributes a single NotesSession that loads remembered facts from a markdown file into the system prompt every turn AND owns a remember(fact) tool the model uses to save new ones. ~280 lines. Consumed by both full-agent/ and sdk-agent/. Demonstrates session-implemented tools (a contributed tool group with the reserved provider = "session"). |
Using Loom as a library
Loom is also a TypeScript library. runAgent(manifest) accepts the
same shape as the TOML file, just as a JS object. You construct
primitives directly when you want a reference to them — e.g. to
hold an AnthropicHarness instance and reuse it across multiple
agents, or to wire a CompactingSession into a /compact slash
command.
import { AnthropicHarness, runAgent, type AgentManifest } from "loom";
const harness = new AnthropicHarness(
"claude-sonnet-4-5",
process.env.ANTHROPIC_API_KEY!,
"https://api.anthropic.com",
4096,
16,
true,
);
const manifest: AgentManifest = {
name: "demo",
systemPrompt: "You are a helpful assistant.",
harness, // instance, not spec
session: [
// layered
{ provider: "compacting", threshold: 60 },
{ provider: "file", path: "./demo.jsonl" },
],
tools: { bash: "builtin", read_file: "builtin" },
capabilities: {
bash: { commands: "*", paths: ["./"] },
read_file: { paths: ["./"] },
},
};
const agent = await runAgent(manifest);
void (async () => {
for await (const update of agent.updates()) {
// render however you want — see examples/sdk-agent/agent.ts for a tiny renderer
}
})();
await agent.prompt("hi");
await agent.close();The manifest mixes spec form and instance form freely. Anywhere
you could write { provider = "...", ...config } in TOML you can
pass either a spec object or a pre-built class instance — useful
for the harness and session slots when you want a direct reference
to the layers.
See examples/sdk-agent/agent.ts
for a full working SDK setup including a tiny update renderer.
Layered sessions
A session in Loom is either a single layer or a stack of layers.
The Session interface defines the composition protocol: push
flows top-to-bottom (each layer may transform, drop, or fan-out the
event; the bottom layer is typically storage), pull flows
bottom-to-top (each layer may rewrite what the layers below
produced; the top is the prompt the harness sees), and every other
hook (tools(), systemPromptSection(), prepareTurn(),
close()) aggregates across layers.
Declare layers with [[session.layers]] (array-of-tables),
[session] layers = [...] (inline; all-strings or all-tables,
a TOML parser quirk), or a SessionSpec[] on
AgentManifest.session (SDK). A singleton [session] with just
provider = "..." is the one-layer case; when absent entirely, the
default chain skills → compacting → in-memory applies — bounded
growth and skill auto-loading out of the box.
Pass-through vs storage. Pass-through layers transform
events flowing through the chain without persisting them
(compacting, skills); storage layers retain them (in-memory,
file). Boot fails with a clear error if every factory-based layer
is pass-through — every turn would see an empty history — so end
your chain with a storage layer. Session authors flag pass-through
factories with passThrough: true; the default is storage-class,
the safe default for third-party sessions.
A layer can also contribute tools. Session.tools() is the
single channel by which tools enter an agent beyond the manifest
itself: it returns labeled tool groups — { label, tools },
where tools is a [tools]-shaped table using the same closed
entry grammar, parsed by the manifest parser itself, and judged
against the effective ceiling at boot (see the
Capabilities reference for the judging
rules). Entries the session implements itself use the reserved
provider "session"; the runtime resolves those names through the
session chain's resolveTool(name, config, agent, capabilities) —
no separate [tools.X] entry needed (this is how the notes example
bundles its remember verb with the session that stores it).
A group may also carry its own providers table — same shape as
the manifest's [providers], but with group-local handles:
lexically scoped to that group's entries ("builtin" and
"session" reserved), substituted with their values before
resolution, and deduped globally by value so identical specs
shipped by several groups share one instance. Entries that resolve
to a group-shipped implementation need instance-name acceptance in
[capabilities] — see
the consent rule.
Mixing pre-built instances into the chain (SDK only)
From code, the session array can be heterogeneous: any entry
can be a string, a SessionSpec, or a pre-built Session instance.
The runtime resolves the named entries and threads everything
through ChainedSession:
import {
CompactingSession,
modelCompactor,
runAgent,
type AgentManifest,
} from "loom";
const compactor = new CompactingSession({
threshold: 60,
compactor: modelCompactor(),
onCompact: ({ before, after }) =>
console.log(`[compacted] ${before} → ${after}`),
});
const manifest: AgentManifest = {
// ...
providers: { notes: { path: "./notes-provider" } },
session: [
compactor, // pre-built Session instance, used verbatim
"notes", // resolved via [providers].notes
"in-memory", // built-in
],
// ...
};
const agent = await runAgent(manifest);
// `compactor` is the same instance the runtime is driving:
await compactor.compactNow(harness); // forced compaction
const used = compactor.tokensInContext; // peek at usageReach for this when you need a handle to a specific layer —
wiring compactor.compactNow() to a /compact slash command, or
reading tokensInContext for a context-usage UI; with the TOML
form, the runtime owns every instance. For the singleton case, pass
the instance directly: session: someSession. See
examples/sdk-agent/agent.ts for
a complete working example.
Capabilities reference
The effective ceiling
The agent's total authority surface — the effective ceiling —
is [capabilities] unioned with the inline capabilities
declarations on the manifest's own [tools] entries; root
declarations are self-authorizing (same author, same file), so
an inline request widens the ceiling rather than exceeding it.
Anything absent from the ceiling is unreachable; contributed
declarations (tool groups from skills and session layers) are
judged against the union and can never widen it. loom audit
prints the computed ceiling at the top of the tree (grants in
--json). Read [capabilities] as the pre-authorization
surface — authority not attached to a tool entry: grants for
bare entries (bash = "builtin" picks up its row), acceptance
rows for contributions that haven't arrived yet, and ceilings
for delegation.
No [capabilities] section ≠ fail open. Absent the section,
the conservative default ceiling applies: exactly the four
FS/shell tools (bash, read_file, write_file, edit_file)
over the working directory — no network, nothing else. Skill
activation needs no read grants (the skills layer's read_skill
tool handles it — see the skills session), so the
default stays this small. Wildcards are always explicit; loom
never widens a grant on its own.
Every tool declares the capability kinds it needs (requires)
and those it may use if granted (optional); grants are per-tool:
[capabilities]
bash = { commands = "*", paths = ["./"], env = ["PATH", "HOME"] }
read_file = { paths = ["./"] }
edit_file = "*" # whole-tool unrestricted
fetch_url = "*"Tool entries select, name, and authorize
A [tools.X] entry never configures. It has exactly three keys:
| Key | Meaning |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| provider | Implementation reference: "builtin", "session", a [providers] handle, a source spec, or the inline form of a configured [providers] entry ({ provider = "mcp-server", npm = "…" }). |
| tool | Underlying tool name when the instance key is a rename. |
| capabilities | The requested grant for this instance. |
Construction config lives on the thing being constructed — a
[providers] entry, [session.X], or [harness]; per-instance
parameters are capability kinds. The inline provider form is just
an anonymous [providers] entry — same parser, same resolution
path, deduplicated by value, so one spec means one process. An
entry omitting capabilities requests the full ceiling entry for
its name (ceiling[instance] ?? ceiling[underlying]); one carrying
it is self-authorizing — both the requested grant and a widening
of the ceiling.
Contributed entries (from session tool groups) are instead judged
against the effective ceiling, fail-soft — each group accepted
atomically or rejected with per-declaration verdicts. A contributed
entry with no capabilities field requests nothing and is always
granted; if its tool needs grants nothing pre-authorized, boot
fails at the ordinary requires check.
Shipping an implementation needs by-name consent. A contributed
entry that references a root-trusted implementation — "builtin",
the reserved "session", or a handle into the manifest's own
[providers] — only needs its request to fit the ceiling. An
entry that ships its own implementation (an inline
configured table or npm/path spec, possibly via a group-local
handle) is an authority claim the grant vocabulary can't see:
materializing a provider loads code or spawns a process before any
verdict-gated tool runs. Such an entry is accepted only when the
instance is named in [capabilities] — judged against that
instance-name entry alone, the underlying-name fallback never
widens acceptance, and a bare request is never silently granted.
Rejections print the provider spec and a paste-ready acceptance
line.
Grant shapes
| Shape | Meaning |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| "*" | Whole-tool unrestricted. Sandbox engagement opts out. |
| { kind = value } | One grant row, where value is "*", an allowlist array (["./src", "./test"]), or a kind-defined object. |
| [ { … }, { … } ] | A capability set: each row is an independent grant; a request is authorized iff it fits entirely within some single row. |
| {} | Nothing granted. Tools with non-empty requires fail boot. |
Capability sets
[capabilities]
bash = [
{ commands = "*", paths = ["./"] },
{ commands = ["gcalcli"], network = "*", paths = ["~/.gcalcli"] },
]A capability set is a union of boxes, not a bounding box — rows never
combine to authorize a request that no single row authorizes. The
grant above does not give "bash" the network: arbitrary shell
commands get a network-less sandbox over ./; only gcalcli gets
the network plus its config directory.
bash picks a sandbox per invocation. A plain cmd args…
invocation of a per-command row's command — bare words and simple
quotes only; no $, backticks, pipes, redirects, or FOO=bar
prefixes — is promoted to direct argv exec (no shell) under
that row's sandbox. Everything else runs under the general
(commands = "*") row's shell, or is refused with a teaching
error when no general row exists.
Per-command rows still reach inside that shell: whenever the
general row engages the sandbox, loom puts a broker shim for
each rowed command on the shell's PATH. Invoking the command by
name — in a pipeline, an interpreter, a script (gcalcli … | curl
…, bash -c "gcalcli …", a Python one-liner that shells out) —
transparently runs that command under its row's sandbox via a
host broker, while everything around it keeps the general row's
privileges. The match is by command name as resolved through
PATH: invoking the binary by absolute path or under a renamed
copy dodges the shim and falls back to the general row. That
fallback is fail-safe — dodging the shim loses the elevated
grant, it never grants one — and the broker only ever elevates a
command a row names explicitly.
What per-command network rows buy you — honestly. They shrink
and itemize the exfiltration surface — loom audit can enumerate
exactly which binaries may touch the network and with what
filesystem view — but they do not eliminate it; nothing does
while one model holds both secret-reads and any network path. Treat
a capability set as a reviewable, enumerable risk list, not a proof of
impossibility.
How a row reaches a pipeline: the command broker
This is the machinery behind "carry their own grants wherever they run." When a capability set has a general row that engages the sandbox, loom stands up a broker for the per-command rows before launching the shell:
- A throwaway directory holds one small shim plus an executable
symlink named after each rowed command (
gcalcli→ shim). That directory is prepended to the sandboxed shell'sPATH, so a baregcalcli— typed, piped, or reached from inside an interpreter — resolves to the shim. - The shim doesn't run anything. It connects a unix socket back to
the loom process, streams a request (
argv,cwd,env) up, then relays stdin up and stdout / stderr / exit-status down. - The broker (inside loom, outside the sandbox) looks up the
row for
argv[0], runs the real command in a fresh sandbox built from that row's grant, and streams the result back.
sandboxed shell: … | gcalcli … | …
│ (PATH resolves gcalcli to the shim)
▼
shim ──unix socket──▶ broker (host, in loom)
│ re-sandbox under
│ the gcalcli row
▼
real gcalcli
◀──stdout/stderr/exit─────┘The command runs with its own filesystem and network view no matter how deeply it is nested, while everything around it keeps the general row.
The model never sees any of this. The bash tool's description
states each command's standing grant — "These commands carry their
own grants wherever they run … gcalcli (network access;
filesystem: ~/.gcalcli)" — so the model treats gcalcli as a thing
that can reach the network and uses it normally. There is no broker
tool and no "escalate" step to learn.
It is fail-closed, and the socket — not the shim — is the trust boundary. The broker:
- runs only commands a row names explicitly; anything else is
refused (
commands = "*"never brokers); - resolves the command against the host
PATH, never the agent's, so an agent cannot plant a fakegcalcliin a writable directory and have its code run under the row's grant — the name maps to the real installed binary; - always re-sandboxes, or refuses when no backend is available (the broker itself runs unconfined, so it never hands authority to an unsandboxed child);
- is bypassable only downward: an absolute path or a renamed copy dodges the shim and falls back to the general row — losing the grant, never gaining one.
Fidelity. cwd (read at the point of invocation, so it is
correct even many processes deep), environment, stdin/stdout/stderr,
and exit status — including signal deaths, reported 128+signum so
pipefail works — round-trip as if the command ran in place.
isatty is the one thing it cannot reproduce: the streams are
pipes, so a brokered command sees a non-tty, exactly as every other
command the bash tool runs does.
The broker is enforced on macOS sandbox-exec today; the Linux
bwrap path uses the same broker and is wired but pending
verification.
The algebra is tool-owned
Containment ("does this grant cover that request?") and merge
("fold two grants into one") are decided by the tool itself via the
optional containsGrant(superset, subset) / mergeGrants(a, b)
hooks; loom only ever compares grants through this algebra. The
default semantics are strict:
- A kind absent from a grant is not granted — there is no
implicit
"*". "*"contains everything; nothing but"*"contains"*".- Capability sets compare row-wise: every subset row must fit entirely within some single superset row.
- The default merge is the deduplicated union of the two capability sets — lossless, never the kind-wise bounding box.
The path-based builtins override containment so paths values
compare lexically (["./src"] is contained by ["./"]); bash
does the same per row. Whatever a tool implements, the runtime
asserts the merge law — merge(a, b) must contain both under
containsGrant — and fails loudly naming the offending tool.
Who owns rows. Loom owns the row form: it's the free join — the only lossless default merge possible without interpreting kinds — so the default algebra and the grant grammar admit it for every tool. Tools own row meaning; the one cross-tool guarantee is that containment never mixes rows, so each row is coherent in isolation. Tool authors pick a tier:
- Indifferent — read kinds via
kindGranted/valueFor(row-aware; first row carrying the kind wins). No row code. - Refusing — call
singleRowGrant(grant, name)at construction; a multi-row grant fails boot with a teaching error instead of misbehaving at execute time. - Embracing — implement
containsGrant/mergeGrantsand dispatch one row per operation, asbashdoes.
Rows only reach a tool when a manifest author writes them or the
default merge folds a contribution onto it — and the latter is
exactly what mergeGrants overrides.
Kinds shipped by the built-in tools
| Kind | Used by | Semantics |
| ---------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| paths | read_file, write_file, edit_file, find, bash | "*" any FS; ["./"] allowlist roots (lexical containment); absent → the tool defaults to the working directory |
| commands | bash | "*" shell mode (any command via bash -c); ["cat", …] argv mode (model picks from list, direct spawn, no shell); rows of both kinds compose in a capability set; absent → boot fails (required) |
| network | bash | "*" allow; [] or absent → deny. Per-host filtering isn't supported by the OS sandboxes. |
| env | bash | Two-tier inheritance — see below |
| manifest | spawn_subagent | An inline sub-agent manifest — the delegated authority itself. See the tool's section. |
The Anthropic harness's server tools add their own kinds
(max_uses, allowed_domains, blocked_domains, user_location,
max_content_tokens, citations) — see web_search below.
Bash env inheritance
Bash inherits environment variables in two tiers. Tier 1 —
always inherited, never overridable (locale and terminal
plumbing): HOME, USER, LOGNAME, SHELL, TERM, COLORTERM,
LANG, LC_ALL, LC_CTYPE, TZ. Tier 2 — default-on,
replaceable (dropped when env is an explicit list): PATH,
PWD, TMPDIR, EDITOR, VISUAL, PAGER.
| Grant | Result |
| ----------------- | ---------------------------------------- |
| env absent | Tier 1 + Tier 2 (the convenient default) |
| env = "*" | full process.env |
| env = [] | Tier 1 only (hermetic-but-functional) |
| env = ["NAME"] | Tier 1 + NAME |
| env = ["AWS_*"] | Tier 1 + prefix match on AWS_ |
Subagent ceiling
[capabilities] is a transitive ceiling across the sub-agent tree:
every sub-agent's grant must be contained by the parent's grant for
the same key, under the strict algebra above. loom audit walks
the whole tree statically and reports violations before they hit
at runtime — and the same containment is enforced at spawn
time: spawning a sub-agent whose effective ceiling exceeds the
parent's throws a CapabilityError naming the offending keys, so
the tool call fails and the sub-agent never boots. Mind the
error's guidance: a sub-manifest with no [capabilities] section
gets the default ceiling, not an empty one — give it an empty
[capabilities] table (capabilities: {} from the SDK) to
request none.
Writing skills
A loom skill is a standard Agent Skills
folder — a directory with a SKILL.md whose frontmatter has name
and description, instructions in the body, and optional
scripts/, references/, and assets/. Any spec-compliant skill
works in loom with no loom-specific metadata at all: it compiles to
the authority-free group { read_skill: {} }. Activation and
bundled-file reads go through the skills layer's read_skill tool,
so a pure-instructions skill requests no authority whatsoever
— no read grants, no paths. On activation the model sees your
SKILL.md body wrapped in <skill name="…" directory="…"> tags, a
note that relative paths resolve against the skill directory, and
a listing of bundled resources it can fetch one at a time — write
the body with that frame in mind.
Loom-specific metadata starts when a skill needs authority — a
command, network access, a tool of its own. Declarations are for
authority only; never declare read_file over your own directory
just to make bundled files readable — read_skill already covers
that. Declare authority in the frontmatter metadata table as
TOML carried in YAML block scalars:
---
name: dns-lookup
description: Look up DNS records with dig. Use when the user asks
what a domain resolves to.
metadata:
loom.tools: |
bash = { capabilities = { commands = ["dig"], network = "*" } }
---
# DNS lookup
Use the `bash` tool to invoke `dig`. Its network grant is bound to
the `dig` command, so it holds even in a pipeline…The keys:
| Key | Contents |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| loom.tools | A [tools] table body — the exact grammar of a manifest's [tools] section, parsed by the same code. Provider-less entries contribute grant rows to a tool the host already has (bash above); entries with provider/tool declare new instances (e.g. jq = { provider = "builtin", tool = "bash", capabilities = { commands = ["jq"] } }). |
| loom.providers | A [providers] table body, for skills that ship software (an MCP server in the skill folder, an npm package). Handles are local to the skill; sources dedup globally by value. |
${SKILL_DIR} substitutes textually — before TOML parsing — to the
skill's absolute directory, so authority declarations can reference
bundled files (args = ["${SKILL_DIR}/server.mjs"] for a shipped
server, paths = ["${SKILL_DIR}"] in a bash row that must execute
bundled scripts) without knowing where the skill is installed.
Declare the minimum. Your declaration is a request, judged
against the host's [capabilities] ceiling — atomically: one
over-broad entry rejects the whole skill, fail-soft, and the skill
vanishes from the model's catalog. The remediation line users see
is generated from your declaration, so a precise request
(commands = ["dig"], not commands = "*") is both your best
chance of fitting an existing ceiling and the consent prompt your
users will be asked to paste. Skills that ship their own
implementation (loom.providers, or a provider source in an
entry) additionally require the host to name the instance in
[capabilities] — fitting the ceiling is never enough to run
contributed code. See
the consent rule.
Write instructions for the sandbox you asked for. If you
declared a per-command row, name the command in the body ("use
dig to look up records"): the broker binds your row's grant to
that command name, so it applies whether the model invokes it
plainly or inside a pipeline. The grant follows the command, not
the shape of the line — but only for the command you named, so be
specific about which binary does the privileged work.
Enhancing a skill you don't author: drop a loom.toml sidecar
([tools] + optional [providers]) next to someone else's
SKILL.md — it wins over frontmatter entirely.
Portability: loom.* metadata keys are spec-legal string
values; other Agent Skills clients ignore them. Use the spec's
compatibility field for human-facing requirements ("requires dig
on PATH"), and keep the body client-agnostic.
Validate with loom audit <agent.toml> against a manifest that
mounts your skill: ACTIVE/INACTIVE per declaration, with reasons.
examples/skills-agent/ has one skill per authoring tier — from
zero-metadata to MCP-server-shipping — and the
skills session docs cover the host-side mechanics.
What's in the box
Loom ships four built-in registries: harness factories, session factories, Tools meta-factories, and native tools. Everything below is available with no install step; manifests reference them by name, the SDK exposes the underlying classes for direct construction.
Harnesses
Referenced as [harness].provider = "<name>" (TOML) or
{ provider: "<name>", ...config } / new XxxHarness(...) (SDK).
Every harness factory declares the secrets it needs at boot via the
standard secrets pipeline.
anthropic
Claude via the Anthropic Messages API. Streams when stream = true
(default). Tool calls are dispatched through the runtime; the
harness handles the tool_use / tool_result round-trip.
| Config key | Default | Notes |
| ----------------- | --------------------------- | ---------------------------------------------- |
| model | claude-sonnet-4-5-latest | Any Anthropic model id. |
| apiBase | https://api.anthropic.com | Override for proxies / self-hosted. |
| maxTokens | 4096 | Per-response output cap. |
| maxTurnRequests | 16 | Tool-call round-trip safety limit per turn. |
| stream | true | When false, the response arrives as one block. |
- Secrets:
ANTHROPIC_API_KEY(required). - ACP capabilities: advertises
promptCapabilities.imageandpromptCapabilities.embeddedContextatinitializetime, so ACP clients know they can attach images and embedded resources. - SDK class:
AnthropicHarness(also implementswithModel(id)andsmallModel()for use undersmall-model-of-parent).
openai
OpenAI via the Responses API (the streaming successor to Chat Completions). Same tool-dispatch shape as Anthropic.
| Config key | Default | Notes |
| ----------------- | --------------------------- | ---------------------------------------------------- |
| model | gpt-5.1 | Any OpenAI Responses-API-compatible model. |
| apiBase | https://api.openai.com/v1 | Override for proxies / Azure / self-hosted. |
| maxOutputTokens | unset | Per-response output cap; omit to use server default. |
| maxTurnRequests | 16 | Tool-call round-trip safety limit per turn. |
| stream | true | When false, the response arrives as one block. |
- Secrets:
OPENAI_API_KEY(required). - ACP capabilities: same as
anthropic(image + embeddedContext). - SDK class:
OpenAIHarness(also implementswithModel(id)andsmallModel()).
small-model-of-parent
Sub-agent only. Clones the parent agent's harness, swapping in a cheaper / smaller model. Lets the parent reach into the runtime for a routing decision or a one-off summary without burning the full frontier model.
| Config key | Default | Notes |
| ---------- | ----------------------- | ------------------------------------------------------------------------ |
| model | parent's smallModel() | Explicit id wins; otherwise the parent harness's recommendation is used. |
- Secrets: inherited from the parent's harness (no separate secret slot).
- Requires: parent harness must implement the optional
Harness.withModel(modelId)method. Both built-inAnthropicHarnessandOpenAIHarnessdo; third-party harnesses opt in by adding the method. - Boots only as a sub-agent — used at the top level it fails at resolve time with a clear error.
test
A deterministic, scripted harness for testing the runtime and tool plumbing without an LLM. Two modes:
Scripted:
script: TurnStep[][](or a function returningTurnStep[]). Each turn consumes one inner script. Steps are{ say }/{ think }/{ call: { tool, input } }/{ stop }.Echo:
{ echo: true }— replays the latest user message as an agent message and ends the turn. Useful for smoke tests.Secrets: none.
SDK class:
TestHarness(used heavily by Loom's own test suite — seetest/for working examples).
Sessions
Referenced as [session].provider = "<name>", [[session.layers]]
entries, { provider: "<name>", ...config }, bare strings in the
session: [...] array, or new XxxSession(...) (SDK).
When manifest.session is absent entirely, the runtime applies the
default chain skills → compacting → in-memory; skills silently
no-ops when ~/.skills is missing.
in-memory
The canonical leaf storage layer. Events live in a process-local array; nothing is persisted. No config; every instance starts empty. Use this as the innermost (bottom) link in a chain when you don't need durability.
- SDK class:
InMemorySession.
file
JSONL append log. Coalesces consecutive same-kind chunks
(agent_message_chunk, agent_thought_chunk, user_message_chunk)
into single events before writing, so the on-disk log stays compact
and readable.
| Config key | Default | Notes |
| ---------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| path | <storage>/session.jsonl | Absolute or relative to manifest dir. Defaults to session.jsonl in the agent's per-agent storage dir when omitted. |
Use as a drop-in replacement for in-memory when you want the
conversation to survive process exit.
- SDK class:
FileSession.
compacting
A pull-side transform that summarises older events past a threshold.
Designed to sit above a storage layer in a chain. On push it's
a passthrough (events flow down to be stored) but it swallows
usage_update events so token usage stays as in-memory metadata
instead of cluttering the durable log.
| Config key | Default | Notes |
| ------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| threshold | 40 | Event-count threshold that trips compaction. Auto-raised to keep + 4 if set at or below keep + 2, so a too-small value won't be honored verbatim. |
| tokenThreshold | unset (SDK only) | Token-count threshold (uses latest usage_update.used); takes priority over threshold when usage data is available. |
| token_fraction / tokenFraction | unset | Trip compaction when used tokens reach this fraction of the last context size (e.g. 0.75). Uses usage_update data; takes priority over the count threshold when available. |
| keep | 10 | Most recent events that survive verbatim. |
| persist (TOML) / persistDir (SDK) | off | Persist compaction state to <storage>/compacting/state.json so summaries survive process restarts. persist = true uses the per-agent storage dir. |
| compactor | heuristicCompactor (SDK only) | Replace with modelCompactor() for model-driven summarisation. |
| onCompact | none (SDK only) | Diagnostic callback fired after each successful compaction. |
Exports compactingMemorySession() and compactingFileSession()
as SDK helpers for the common chains. Hold a reference to the
CompactingSession instance (via the heterogeneous-array shape
above) to drive compactNow() imperatively from a slash command.
- SDK class:
CompactingSession. - Pairs well with:
modelCompactor({ instruction, systemPrompt, fallback }).
skills
Discovers Agent Skills folders (directories
with a SKILL.md YAML-frontmatter file) and contributes three
things:
- A catalog — a system-prompt section listing each usable
skill's name, path, and description, telling the model to
activate skills with
read_skill. - The
read_skilltool — contributed through the same channel as everything else: the session's first tool group ("skills session") declaresread_skill = { provider = "session" }, resolved by the session itself. - Tool groups — each skill compiles to a labeled
[tools]-shaped table declaring the tools and grants it needs, contributed throughSession.tools().
read_skill. Schema { skill, path? }. Called with just
skill, it activates: the response wraps the SKILL.md body in
<skill name="…" directory="…">…</skill> tags, notes that
relative paths in the instructions resolve against the skill
directory, and appends a listing of bundled resources (capped at
50 entries) — listed, never eagerly loaded. Pass path to read
one bundled file. Trimmed skills are invisible through it: a
rejected or post-boot-disqualified skill can't be activated or
read. Because read_skill runs as session code — no grant check,
no OS sandbox — it self-polices containment, realpathing both
sides so symlinks can't escape the skill directory.
| Config key | Default | Notes |
| ---------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
| root / roots | ~/.skills | One root or many. Relative paths resolve against manifest dir; ~ expands to OS home; missing roots are silently skipped. |
(default_tools no longer exists — skills declare the tools they
need themselves, and boot fails with a pointer if you set it.)
The skill→tools compiler. Precedence, per skill directory:
- A
loom.tomlsidecar next toSKILL.mdwins entirely — its[tools]table (plus an optional[providers]table) is the group. This is the "enhance a skill you didn't author" path. - Else the frontmatter
metadatakeysloom.tools/loom.providers: TOML snippets carried as YAML block scalars. - Else the authority-free default: the group
{ read_skill: {} }— activation and bundled reads go throughread_skill, so the skill requests no authority at all.
${SKILL_DIR} substitutes textually (before the TOML is parsed) to
the skill's absolute directory. Declarations are parsed by the
manifest parser itself, so authoring errors and capability
semantics are identical across manifests, the SDK, and skills —
see the Capabilities reference for how
contributed entries are judged.
Skills can ship software. A skill's [providers] declarations
(frontmatter loom.providers or the sidecar table) are the
group-local handles described under
Layered sessions. Because the skill carries
its own implementation, fitting the ceiling isn't enough: the host
manifest must name the instance in [capabilities], and that
line is the consent to run the skill's code (see
the consent rule).
examples/skills-agent/skills/echo-notes/ bundles an MCP server
this way, accepted by the manifest's echo_note = "*" entry.
Verdicts. Groups are judged at boot — fail-soft, atomic per
group, with paste-ready [capabilities] TOML remediation per
rejected declaration. Under run, rejected skills are trimmed
from the catalog — the model never sees them; under audit,
they're listed as INACTIVE with the reason.
Post-boot is subtractive only. Roots rescan each turn, but a skill that appears mid-session can at most reference already-granted authority: if its declarations exceed the ceiling it's trimmed, and if it declares new tool instances it stays inactive until a restart binds them.
Worked example. A calendar skill that needs gcalcli:
---
name: calendar
description: Read and edit Google Calendar with gcalcli.
metadata:
loom.tools: |
bash = { capabilities = { commands = ["gcalcli"], network = "*", paths = ["~/.gcalcli"] } }
---
Run `gcalcli agenda` to …The skill declares; the user accepts by covering the declaration in the ceiling:
[capabilities]
bash = [
{ commands = "*", paths = ["./"] },
{ commands = ["gcalcli"], network = "*", paths = ["~/.gcalcli"] },
]At boot the declaration passes containment (the gcalcli row
covers the bash request row-for-row) and the skill shows in the
catalog. Drop the second bash row and the skill is trimmed
instead. Note what's not here: no read grant of any kind —
activation and bundled reads ride read_skill. Mounting a skills
root in [session] is the consent to a skill's instructions;
any authority it wants still has to clear this ceiling.
For a runnable tour of all five tiers — authority-free
(read_skill only), frontmatter bash row, sidecar-renamed
instance, skill-shipped MCP server, deliberate rejection — see
examples/skills-agent/. Skills with
malformed frontmatter are skipped quietly so boot stays resilient.
- SDK class:
SkillsSession.
fork-of-parent
Sub-agent only. Snapshots the parent's events into a fresh
InMemorySession at fork time. Subsequent appends on either side
don't bleed across — the sub-agent gets the parent's context as a
starting point and writes its own log from there. No config.
Tools meta-factories
Referenced in [providers].<handle> = { provider = "<name>", ... }.
A meta-factory is a Tools provider the runtime can instantiate by
name (no on-disk package needed). The same instance can back many
[tools.X] entries.
mcp-server
Any Model Context Protocol server
— stdio transport, spawned + adapted. One [providers] entry per
server instance; one [tools.X] entry per exposed tool. Tools can
be renamed (tool = "original_name"), narrowed (capability grant
binds args), and constrained by enum (array grant). See the full
section below for the integration tour.
| Config key | Default | Notes |
| ------------------ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| command + args | required (or npm) | Generic stdio launcher. |
| npm | required (or command) | Shorthand for npm-distributed servers. Equivalent to command = "npx" + args = ["-y", "<pkg>"]. |
| env | none | Static env vars passed to the spawned process. |
| secrets | {} | Map of LOOM_SECRET_NAME = "ENV_VAR_NAME". Each LHS is resolved through Loom's secret store and injected into the child's env under the RHS name. Per-instance, marked as required at Phase 1. |
Use loom mcp inspect <spec> to discover a server's tool list as
paste-and-prune TOML (see CLI reference).
Native tools
Referenced as [tools.<name>] = "builtin" or omitted entirely (the
four file-system tools are auto-loaded with default capabilities
when [tools] is absent). Every tool has a static capability
requires/optional contract; manifest grants must cover the
requires set for boot to succeed.
bash
Execute a shell command via /bin/bash -c. Engages the platform
sandbox (sandbox-exec on macOS, bwrap on Linux) when the grant
is structured, with the profile derived from the same grant the
model sees — one source of truth for both the model's mental model
and the OS-level confinement.
| Input field | Type | Notes |
| ------------ | ----------------- | -------------------------------------------------- |
| command | string (required) | The command to run. |
| cwd | string | Working directory; must be inside an allowed path. |
| timeout_ms | number | Default 30000. |
- Capability kinds: requires
commands; optionalpaths,network,env.commands = "*"→ shell mode: input is a free-formcommandstring, dispatched via/bin/bash -c "…".commands = ["pwd", "cat"]→ argv mode: the input schema enumerates the allowed commands and exposes anargsarray; dispatch is directspawn(cmd, args, …)with no shell. Useful for scoped sub-agents that should only run a handful of programs.- A capability set mixes both — see Capability sets for the dispatch and attribution rules, and [the command broker](#how-a-row-reaches-a-pipeline-the-command
