npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mcmaki/loom

v0.1.15

Published

Loom — A manifest-driven agent meta-harness

Readme

Loom

A capability-secure, manifest-driven agent runtime.

npm version npm downloads license node

[!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/loom

Or run it without installing:

npx @mcmaki/loom run agent.toml

To use Loom as a library in your own project:

npm install @mcmaki/loom

Then point it at your manifest:

ANTHROPIC_API_KEY=... loom run agent.toml

Status: 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_file tool has capabilities for describing which paths it can read, while a send_discord_dm tool 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 help

Requires 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.toml

loom 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.toml

Read 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 (compactingnotesin-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 usage

Reach 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's PATH, so a bare gcalcli — 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 fake gcalcli in 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:

  1. Indifferent — read kinds via kindGranted/valueFor (row-aware; first row carrying the kind wins). No row code.
  2. Refusing — call singleRowGrant(grant, name) at construction; a multi-row grant fails boot with a teaching error instead of misbehaving at execute time.
  3. Embracing — implement containsGrant/mergeGrants and dispatch one row per operation, as bash does.

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.image and promptCapabilities.embeddedContext at initialize time, so ACP clients know they can attach images and embedded resources.
  • SDK class: AnthropicHarness (also implements withModel(id) and smallModel() for use under small-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 implements withModel(id) and smallModel()).

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-in AnthropicHarness and OpenAIHarness do; 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 returning TurnStep[]). 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 — see test/ 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_skill tool — contributed through the same channel as everything else: the session's first tool group ("skills session") declares read_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 through Session.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:

  1. A loom.toml sidecar next to SKILL.md wins entirely — its [tools] table (plus an optional [providers] table) is the group. This is the "enhance a skill you didn't author" path.
  2. Else the frontmatter metadata keys loom.tools / loom.providers: TOML snippets carried as YAML block scalars.
  3. Else the authority-free default: the group { read_skill: {} } — activation and bundled reads go through read_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; optional paths, network, env.
    • commands = "*"shell mode: input is a free-form command string, dispatched via /bin/bash -c "…".
    • commands = ["pwd", "cat"]argv mode: the input schema enumerates the allowed commands and exposes an args array; dispatch is direct spawn(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