Handler — Addons¶
Status: implemented (2026-09-25). Normative: contracts.md §G (the handler.addon/v1 and
handler.binding/v1 contracts). As users see it, installing and writing one:
addons.
1. Goal¶
Give agents the ability to act on a broader world (a store, a CI server, a log cluster, a test environment, internal APIs) without baking every tool into the toolbox image, and without handing an unsupervised agent more power than its role needs.
The toolbox stays the shared development environment. A tool used a few times a year must not grow it, nor force a rebuild of every harness image when it changes.
2. Use cases¶
| # | Use case | What the agent needs |
|---|---|---|
| UA1 | Work on a Shopify theme with the Shopify CLI | a tool (the CLI), a store, a token, egress to Shopify, a skill |
| UA2 | Read the logs of our services in OpenSearch | no tool (curl is in the toolbox), an endpoint, a login, a skill (indices, query syntax) |
| UA3 | Push a branch, wait for Jenkins, deploy to dev, test the deployed API | git, Jenkins, a deployment target, the app's API, each with its own access |
| UA4 | Role agents: dev, QA, security | the same systems as UA3, with different rights per role |
UA1 is about packaging a tool. UA2 to UA4 are about granting access: the hard part there is credentials and permissions, not binaries.
3. Concepts¶
An addon packs everything that makes a capability usable. Its parts are not all of the same kind:
| Part | Shopify (UA1) | OpenSearch (UA2) | Jenkins (UA3) | Generic or yours? |
|---|---|---|---|---|
| Tool (binaries) | Shopify CLI | none, or an MCP server | often none (curl) |
generic, optional |
| Skill (knowledge) | theme workflow, what is safe to run | index names, query syntax, how to find a service's logs | job names, how to wait for a build | generic, plus your conventions |
| Egress | *.myshopify.com:443, *.shopify.com:443 |
the cluster | the Jenkins host | generic pattern, your hosts |
| Settings (non-secret) | the store | the cluster URL | the Jenkins URL | yours |
| Credential | theme token | login / password | API token | yours |
| Rights | development store only | read-only on logs-* |
build app-dev only |
yours, per role |
This gives three layers, each defined in one place:
| Layer | Where | What it holds | How many |
|---|---|---|---|
| Addon | a folder <name>/: built-in (handler_harness/data/addons/) or the user's (~/.config/handler-ai/addons/) |
how to use a kind of system: tool image (optional), skills, env, setting and secret slots, egress pattern, provisioning recipe. Generic, shareable, versioned. | one per kind of system: shopify, opensearch, jenkins |
| Binding | ~/.config/handler-ai/bindings/<name>.toml |
one addon bound to one instance of the system: settings, credential references (and, later, access method). Its rights are those of the dedicated account behind the credential (§8.1). | one per system instance: shopify-dev-shop, jenkins-dev, jenkins-prod-readonly |
| Selection | the agent | a list of binding names | per agent |
Why selection is per agent, not per account: addons follow the project (Shopify matters for the theme repository, not for this one), and an agent works on one project (or ad hoc in one folder). Binding them to accounts would need an account for every combination of login and project.
Why credentials are not typed into the agent: the same shop would be re-entered for every
project that uses it, and credentials would be scattered across agents.json. A binding
defines each credential once, named and reviewable; an agent only names bindings.
The same addon can back several bindings (Jenkins dev with build rights, Jenkins prod read-only).
4. Packaging: the addon image¶
4.1 Layout¶
One directory per addon, built-in (handler_harness/data/addons/, next to harnesses/) or the
user's (~/.config/handler-ai/addons/):
addons/<name>/
├── addon.toml manifest (handler.addon/v1)
├── Containerfile what gets installed; optional (an addon may have no tool)
├── skills/
│ └── <skill>/
│ └── SKILL.md how the agent uses it
└── PROVISIONING.md how to create the least-privilege account the addon expects (§8.1)
Skills sit in skills/<skill>/ rather than a single skill/ folder: render names each mounted
skill after its folder, so every addon's skill would otherwise be called skill.
4.2 Containerfile¶
Two stages: install at build time in the toolbox (same Node, Python, glibc as the sandbox that
will run the tool), then keep only the installed tree in a FROM scratch image.
ARG TOOLBOX_IMAGE=localhost/handler-ai-toolbox:latest
FROM ${TOOLBOX_IMAGE} AS build
ARG PREFIX=/opt/addons/shopify # build at the path where it will be mounted
RUN npm install --prefix "$PREFIX" --no-fund --no-audit @shopify/cli \
&& npm cache clean --force \
&& mkdir -p "$PREFIX/bin" \
&& ln -s ../node_modules/.bin/shopify "$PREFIX/bin/shopify" \
&& echo "Shopify CLI $(jq -er .version "$PREFIX/node_modules/@shopify/cli/package.json")" > "$PREFIX/version" \
&& rm -rf /home/agent
FROM scratch
COPY --from=build /opt/addons/shopify /
Conventions:
- Build at the mount path,
/opt/addons/<name>: any absolute path baked in at install time still resolves in the sandbox (the same idea as same-path scope mounts). bin/holds symlinks to the commands the addon provides. It is the only directory the launcher puts onPATH, whatever the packaging (npm prefix, uv venv, static binary).versionat the root: one line for people, like/usr/share/handler/versionfor harness images (B.6). The build fails if it cannot be read.- Install at build time, never in an init script: an install at boot needs network at every start, is slow, and changes version silently.
4.3 Mounting¶
podman run … \
--mount type=image,src=<shopify image id>,dst=/opt/addons/shopify \
--mount type=image,src=<httpie image id>,dst=/opt/addons/httpie \
-e PATH=<the image's PATH>:/opt/addons/shopify/bin:/opt/addons/httpie/bin
Addons are mounted by image id, resolved from :latest when the agent starts, not by tag
(§4.5).
- Read-only, one mount per addon, each at its own path: addons never overwrite each other and can carry different versions of the same library.
- Addons are appended to
PATH: the toolbox always wins, so an addon cannot replacenodeorgit. - Nothing is built at launch, and there is no image per combination of addons: an agent with five addons is five mounts.
4.4 Skills stay on disk¶
Skills are mounted read-only from addons/<name>/skills/<skill>/, like the skills of
~/.config/handler-ai/skills/, not copied into the image. Skills change far more often than tools, and
fixing a sentence must not need a rebuild.
4.5 Rebuilds¶
Same flow as harness images (D.4), with a new target addon:<name>:
- build an untagged image without cache;
- check: mount it into the toolbox with no network and run the manifest's
checkcommand (e.g.shopify version); - tag it
:latest(the image it replaces is removed once unused).
Addons use the toolbox's runtimes, so a toolbox rebuild also rebuilds and checks every addon image already built (one never built is not in use), and switches all of them or none, as it does for harnesses.
Unlike a harness image, an addon image in use is not protected by podman: podman image rm
refuses to remove a container's own image, but not an image the container only mounts (found
2026-09-25: two switches while an agent had the addon mounted removed its image; the agent kept
running on the old files). So the sandbox mounts an addon by image id, not by tag, each
container is labelled with the ids of the addon images it mounts, and the runtime's image remove
(contract A.7) refuses an image a container's label says it mounts.
Addon images are built with --no-intermediate (podman --layers=false): with layer caching, the build stage of the two-stage
Containerfile stays behind as an untagged image after every build.
4.6 Why an image rather than a folder of binaries¶
At runtime an addon is a read-only folder with binaries and instructions, i.e. a skill with its tools. The image is the better format to build and ship that folder:
| Folder (e.g. a skill with binaries) | Addon image | |
|---|---|---|
| Where it is built | must be built in the toolbox anyway, then copied out | podman build does exactly that |
| Recipe | separate or implicit | the Containerfile, in git |
| Changes after install | a host folder can change mid-session | immutable, content-addressed |
| Update | manual | build → check → :latest |
| Another machine | copy and hope it matches | podman push / pull |
5. Manifest, bindings and selection¶
5.1 Addon manifest (handler.addon/v1)¶
Generic: it declares slots, a binding fills them.
# addons/shopify/addon.toml
apiVersion = "handler.addon/v1"
description = "Shopify CLI: themes and apps"
image = "localhost/handler-ai-addon-shopify" # no tag: the platform owns :latest
commands = ["shopify"] # exactly what bin/ provides (checked at build)
check = ["shopify", "theme", "list", "--help"] # offline: `shopify version` needs the network
env = { SHOPIFY_CLI_NO_ANALYTICS = "1" }
egress = ["theme-kit-access.shopifyapps.com", "*.shopify.com", "*.shopifycdn.com",
"*.myshopify.com", "registry.npmjs.org"] # seen in log-only mode
[settings.store] # setting slot → env var
env = "SHOPIFY_FLAG_STORE"
required = true
[secretSlots.theme_token]
env = "SHOPIFY_CLI_THEME_TOKEN"
[secretSlots.partners_token]
env = "SHOPIFY_CLI_PARTNERS_TOKEN"
Skills are found in skills/, no key needed. commands is declared so that name clashes can be
checked without opening images.
5.2 Binding (handler.binding/v1)¶
One file per binding in ~/.config/handler-ai/bindings/ ($HANDLER_BINDINGS_DIR), named after the
file, TOML like accounts and providers (unknown keys are errors):
# ~/.config/handler-ai/bindings/shopify-dev-shop.toml
apiVersion = "handler.binding/v1"
addon = "shopify"
description = "Development store, theme-only token"
settings = { store = "my-dev-shop.myshopify.com" }
secrets = { theme_token = { command = ["pass", "show", "shopify/dev-theme-token"] } } # SecretRef (E2)
A binding is invalid if its addon is unknown, a slot it binds is not declared by the addon, or a required setting or secret is missing. An invalid binding is listed with its error and cannot be selected.
Two bindings of the same addon cannot be used by the same agent: they would set the same env vars and mount the same image. Jenkins dev and Jenkins prod go to different agents.
5.3 Selection¶
- Agent:
bindingsin its settings (AgentRequest, D.8;POST /v1/agentsfor an ad-hoc one, CLI--binding); persisted inagents.json. In the web UI, Addons in the agent's form (addon: configuration). - Not in an account or provider: they say how a harness reaches a model; which addons an agent gets is the agent's choice, so it is visible where the agent is set up.
Bindings are fixed when an agent starts (mounts are set when its container is created). Changing an agent's bindings applies to the next session, not a running one.
5.4 Resolution¶
At each agent start, the control plane resolves the selected bindings into the SandboxSpec:
image mounts (by id), PATH suffix, env, settings, secrets (through the existing reference → env
path, with redaction, B3), egress merged into the allow-list, skills mounted read-only (added to the
skills render already mounts, so a harness without skill support fails with the existing B5
error).
Addons whose settings name hosts (OpenSearch, Jenkins) take their egress from those settings rather than from a fixed list; the exact form is an open question (§11).
5.5 Not in the project folder¶
A file in the project (e.g. .handler.toml listing bindings) is tempting, but the project folder
is mounted read-write into the agent: it could add opensearch-prod to that file and get the
binding at its next start. At most such a file could suggest bindings that a person
confirms in the UI (§11).
6. Limits¶
| Limit | Handling |
|---|---|
Tools must be self-contained (npm, pip/uv, Go or Rust binaries). A pacman package with shared libraries in /usr/lib, a daemon or device access does not fit. |
Put it in the toolbox if it is used often. Otherwise, a later addon kind (§7). A pacman install into a prefix plus LD_LIBRARY_PATH is possible but breaks on hard-coded /usr/share paths: not a foundation. |
| Two addons provide the same command. | The agent is rejected when it is saved, naming both addons (from their declared commands). |
| An addon command is hidden by a toolbox command (added later to the toolbox). | Caught by the addon's check, which also runs on every toolbox rebuild (§4.5). |
| Shared dependencies are duplicated between addons. | Accepted: tens of MB per addon. |
| An addon depending on another addon. | Not supported: an addon relies only on the toolbox. |
7. Alternatives considered¶
| Approach | pacman possible? | Built per combination? | Verdict |
|---|---|---|---|
| Put it in the toolbox | yes | no | right for tools used often; wrong for rare ones |
| Addon image mount (§4) | no, self-contained tools only | no | chosen default |
Image built per combination (FROM harness + each selected addon's install step) |
yes | yes, cached | the only way to allow pacman; a possible second addon kind, same manifest and bindings |
| Nix store mounted read-only | not needed: packages carry their dependencies, deduplicated | no | the most rigorous; kept in reserve if the self-contained limit is hit often |
| pixi / conda, Homebrew on Linux | partly | no | weaker versions of the Nix idea |
| Install at boot (npx, uvx, pacman in an init script) | yes | no | rejected: not explicit, network at every boot |
| Host folder / skill with binaries | no | no | same runtime as an image, weaker packaging (§4.6) |
Agent runs a tool container itself (nested) |
yes | no | loosens confinement to call a tool |
| Sidecar or MCP server outside the sandbox | yes | no | kept for credentials and narrow operations (§8.3), not for tools in general |
8. Access: credentials and rights¶
In bypass mode the agent acts unsupervised, and what it reads can steer it: a crafted line in the
logs it reads from OpenSearch is a prompt injection. An agent that holds a git token and has network
access can then leak data or push code. So:
- A skill says what the agent should do. It enforces nothing.
- A wrapper inside the sandbox enforces nothing either: the agent can call the real binary,
or send the same request with
curl. Enforcement can only happen where the credential is used: the gateway or the target system.
8.1 Rights live in the target system¶
A role's policy is the accounts it has. Each role gets a dedicated account in each system, with only the rights it needs, enforced by the system itself:
| System | Native mechanism | Example for the dev role |
|---|---|---|
| git (GitHub / GitLab) | fine-grained token or deploy key per repository, branch protection | push to agent/*, never main |
| OpenSearch | security roles on indices and actions, field-level masking | read-only on logs-*, sensitive fields masked |
| Jenkins | matrix / project-based authorization | build app-dev, no configuration changes |
| Kubernetes / cloud | RBAC / IAM | the dev namespace only |
| Shopify | custom-app scopes, Theme Access | a development store, not the real shop |
Why the system rather than platform rules:
- it understands its own actions: HTTP rules cannot say "no live push" when everything goes
through one
POST /graphql; - it maintains its own permission model: nothing to update when a CLI gains a command;
- it is an filtered: an account without a right cannot use it, whereas a wrapper that rejects known commands fails open on new ones;
- its own audit log attributes every action to the agent's account.
The maintenance moves from code that filters to a recipe that creates the right account: each
addon ships a PROVISIONING.md (the OpenSearch role definition, the Jenkins permission matrix,
the git branch protection rule). The account is created once; the system enforces it from then on.
8.2 When native rights are too coarse¶
In this order:
- Separate environments. The agent only holds credentials for dev or staging: it cannot deploy to production because it has no production credential.
- Request, don't execute. Dangerous actions stay behind existing processes: the agent pushes a branch and opens a PR, a human merges; the pipeline deploys, with a manual approval step where it matters.
- A narrow operation service, only for what 1 and 2 do not cover: a small MCP server outside
the sandbox with two or three allowed operations (e.g.
restart(service, env="dev")), holding the credential itself. Closed by design, and small enough to maintain.
8.3 How a credential reaches the tool¶
From safest to weakest; each binding declares its method:
| Method | Fits | Secret in the sandbox? |
|---|---|---|
Gateway endpoint: the agent calls http://<name>.handler, the gateway adds the credential and handles TLS outward |
HTTP APIs: OpenSearch, Jenkins, internal APIs | no |
| Phantom token: the env holds a stand-in, the sandbox's proxy swaps it for the real value towards the secret's hosts (contracts E3, A.3). What a binding's secrets get by default | CLIs and APIs that call fixed HTTPS hosts (Shopify CLI); the sandbox trusts its proxy's CA | no |
| Forwarded socket (ssh-agent) | git over SSH | no: the key can be used, not read |
| Operation service outside the sandbox (§8.2, 3) | dangerous actions | no |
Env secret (a secret slot's delivery = "env") |
a tool that signs with the secret or speaks something else than HTTP | yes |
8.4 What the gateway does, and does not do¶
The gateway is the per-sandbox proxy (A.3) and keeps a narrow job that does not depend on what each system means:
- inject credentials: the agent cannot read a token, so it cannot leak it or use it elsewhere; the token only works through the gateway, toward its destination;
- destination allow-list (already in place);
- audit log per session, reviewable afterwards (possibly by a security agent reading the other agents' logs);
- kill switch: cut a binding live, like
set-egresstoday; - at most a cheap safety net such as "GET only" for a read-only binding.
It is not where policy lives (§8.1).
9. Roles¶
A role is an agent and its bindings: each project's agent for that role selects the bindings of that project. The rights come from the accounts behind the bindings (§8.1):
| Role | git | Jenkins | Deployment | Test API | Logs |
|---|---|---|---|---|---|
| dev | push to agent/* |
build app-dev |
dev only | yes | read |
| QA | read | read | none | yes | read |
| security | read | none | none | read-only scan | read |
Each role has its own accounts, hence its own bindings (jenkins-dev-build,
jenkins-dev-read, …), so every system's audit log says which role did what.
Nothing restricts which bindings an agent may select: with a single user, a restriction would only protect that user from themselves, at the cost of another list to maintain. It can come back if bindings are ever shared between people.
10. Workflows (out of scope here)¶
"Push, wait for Jenkins, deploy, test, then QA and security review" is orchestration, not addons. It needs, beyond today's unattended runs (D.18):
- waiting without burning tokens: the run ends, and an event (a Jenkins webhook, a finished build) starts the next one, instead of a model loop polling for 20 minutes;
- handoff: the next run receives structured inputs (branch, commit, deployed URL, report), not only a prompt;
- human gates: approving a deployment or a merge.
The direction: an event starts an automation (contracts §I: when this happens, run that); a script orchestrates the parts and passes the data between them; a part with other access is an agent run (an agent and its bindings) or a service, each in its own sandbox; a human gate is a question in the person's inbox.
11. Open questions¶
- Egress for addons whose hosts come from settings (OpenSearch, Jenkins): derive it from URL settings, or declare it explicitly in the binding?
- Access method per binding (§8.3): chosen in the manifest, the binding, or both (manifest lists the supported ones, binding picks)?
- MCP servers hosted outside the sandbox, behind a gateway (those in the sandbox are an addon's
[mcp] command,browser.md§5.2). - A project file that suggests bindings, confirmed by a person in the UI (§5.5): useful once there are many projects?
Decided: addons are both built in and the user's own (~/.config/handler-ai/addons/, replacing a
built-in one of the same name; contract G.1); the name binding ("connection" already meant an
ACP connection).
12. Not built yet¶
- Gateway endpoints (§8.3, §8.4): the agent calls
http://<name>.handlerand the gateway adds the credential, for HTTP APIs whose hosts come from settings (OpenSearch, Jenkins). - An audit log per session and a kill switch per binding (§8.4).
- Credential files kept out of the sandbox (phantoms cover env secrets only).