Ability System
On this page
Status: implemented. add-ability/upgrade-ability/remove-ability
all work end-to-end as specified below — this document is now the design
reference for how they work, not a pre-implementation spec.
The problem this solves
Today, giving an agent a new capability that talks to some external system means hand-authoring three independent artifacts, with nothing tying them together:
- One or more tool files in
agents/<name>/tools/*.ts(hand-written, or admin-generated viaweb/http-tool-admin.ts). - A skill (
SKILL.md) teaching the agent how/when to use them, if the usage pattern isn't obvious from the tool's own description alone. - One or more
actauth.ymlrules — without them,default_decision(usuallydeny) blocks the tool outright.
A real integration (see the customer-support work this spec grew out of) is always all three together — two order-lookup tools, a skill describing the inbound message format, and the actauth rules allowing them. There is currently no way to package that as one shareable, installable unit; every agent that wants it re-derives it from scratch.
A loopengine ability is that unit: a bundle of tool files, skill
directories, and actauth rule snippets, installed by copying files into
an agent's own tree — not by adding a runtime node_modules dependency.
Private vs. public abilities
The example above is a private ability, and it's worth being
explicit about that rather than implying broader reuse than it
actually has: get_order_onway/get_order_shipments_detail hardcode
one company's own API shape (its specific ransack-style query params,
its specific endpoint structure) — reusable across that company's
own agents and environments (staging vs. prod, different mailboxes),
not by some other company, since nobody else's backend speaks that exact
API.
That's a legitimate, common case on its own — most abilities most
companies will ever write are private, internal-reuse abilities like
this one, published to a private registry or just a git repo, never
intended for a public catalog. A public ability is the narrower
case: built against a genuinely multi-tenant API (Stripe, Shopify,
Zendesk) that many different companies' agents could equally call.
Nothing in the format above distinguishes the two — the difference is
just where you publish it and who your tools' own fetch() calls
actually point at — but the public catalog considered under "out of
scope for v1" below only makes sense for the latter kind.
Why copy, not import
The alternative — publish tools as an npm package, import them at
runtime — was deliberately rejected. It's inconsistent with a rule
already established for admin-generated tools (web/http-tool-admin.ts's
own header comment on generateToolCode): a tool should be "the same
class of artifact a human would have written by hand," real, readable,
hand-editable, sitting in the agent's own tools/ directory — not an
opaque import whose behavior lives outside the project and outside
actauth's ability to have been reasoned about at review time.
Copying means:
- An ability's tool code is reviewable in the same PR that installs it — it's just files in the repo, like any other change.
- It can be hand-edited after install, same as an admin-generated HTTP
tool already can be (with the same tradeoff: hand-edits and future
ability upgrades can now conflict — see "Upgrading" below, which
reuses the exact merge machinery
create-loopengine upgradealready has for this reason). - No new "trust this code at import time" surface — actauth already governs calling a tool; this keeps installing one just as visible.
Ability format
An ability is a directory (published to npm or git, same distribution
create-loopengine itself already uses) with this shape:
my-order-tools/
package.json # name, version — an ordinary npm package
loopengine.ability.json # the manifest (see below)
tools/
get_order_onway.ts
get_order_shipments_detail.ts
skills/
order-lookup/
SKILL.md
actauth/
rules.yml
loopengine.ability.json:
{
"loopengineVersion": "^0.1.10",
"tools": ["tools/get_order_onway.ts", "tools/get_order_shipments_detail.ts"],
"skills": ["skills/order-lookup"],
"actauth": "actauth/rules.yml",
"env": [
{ "name": "EM_ACCESS_TOKEN", "description": "Order API access token", "secret": true },
{ "name": "EM_BEARER_TOKEN", "description": "Bearer token for onway/shipments_detail", "secret": true }
]
}
No name/version here — those are read off the ability's own sibling
package.json instead, which already has to exist (and already has to
carry real values) for npm pack to treat the directory as a fetchable
package at all. Declaring them a second time in this file would just be
two numbers to keep in sync instead of one.
tools— file paths, relative to the ability root, each expected toexport const <camelCase> : ToolDefinition, one per file — same shapegenerateToolCode's own output already has, so hand-written and admin-generated tools are both valid ability contents unmodified. Two abilities declaring the same tool name (both ship a "web_search" tool, say) can coexist too: if the bare name is already taken, the second one installs astools/<ability-name>__<name>.tsinstead, with its actual model-facingnamefield overridden to match (via a small generated wrapper intools/index.ts, not by editing the ability's own file — see "Installing" below for why a tool needs this extra step where a skill doesn't).skills— directory paths, each a completeSKILL.md(+ any scripts/assets alongside it), copied as-is into the installing agent'sskills/directory. Two abilities declaring the same skill id (both ship a "web-search" skill, say) can coexist: if the bare id is already taken, the second one installs namespaced under its own ability name instead (skills/<ability-name>/<id>/) rather than refusing outright — see "Installing" below for why this is safe for skills specifically, unlike an actauth rule name collision.actauth— one YAML file of rule objects (name,scope,tool,decision— same shapeagents/<name>/actauth.ymlalready uses), appended into the installing agent's ownactauth.ymlrather than replacing it.loopengineVersion— a semver range, checked against the installing project's ownloopenginedependency before anything is written; refuse rather than install something that imports an export the installedloopengineversion doesn't have yet (this is exactly the gap that caused the Parallel-safe checkbox to silently do nothing earlier — an ability install is a second place that exact failure mode can recur if unchecked).env— optional. Everyprocess.env.Xan ability's tools actually read (the same env-sourced secrets the HTTP tool builder's own{{ENV_VAR}}header syntax already produces, or a hand-written tool's ownprocess.envread — either way, declared once here rather than buried in each tool file for an installer/admin to have to go find).secret: truemeans the Admin UI never echoes the value back once set — see "Managing ability secrets in the Admin UI" below.
Installing
npx loopengine add-ability <npm-package> --agent customer-service
A loopengine subcommand, not a create-loopengine one — two reasons.
First, precedent: bin/cli.ts already has scaffoldAgent, adding a new
agent to an already-scaffolded project, the same granularity of
operation as adding an ability to one — that's loopengine's own
territory already, not create-loopengine's (whole-project creation and
template-file upgrades only). Second, reliability: create-loopengine
is never a dependency of the scaffolded project itself (its own
template's package.json only lists loopengine/actauth), so every
npx create-loopengine@latest ... invocation
re-fetches the CLI package over the network — fine for upgrade, a
rare per-release operation, but not for something that could be invoked
as often as adding an ability might be. loopengine add-ability runs
off the project's own already-installed node_modules/.bin/loopengine
instead.
Named add-ability — add, not install, for the same reasoning
shadcn/ui's own npx shadcn add <component> already uses: "install"
implies a live dependency you import; this copies files into the
project instead, and the verb should say so. The -ability suffix (not
just bare add) keeps it alongside upgrade-ability/remove-ability
as one clearly-related family, and leaves add itself free — add-agent/
add-subagent already exist as their own, unrelated commands on this
same CLI.
What it does, in order — refusing outright, before writing anything, on the first check that fails:
Version check. Read the installing project's own
package.jsondependencies.loopengine; refuse if it doesn't satisfy the ability'sloopengineVersionrange.Fetch.
npm pack <package>into a temp dir and extract — the exact techniquefetchPublishedTemplateDiralready uses to pull a historicalcreate-loopenginetemplate; no new fetch mechanism needed.Collision check. For every actauth rule, check whether a rule of that
namealready exists in the targetactauth.yml— refuse the whole install if it does, same "refuse rather than guess" ruleHttpToolExistsError/HttpToolIndexShapeErroralready enforce for a single admin-created tool, just applied ability-wide so an install is all-or-nothing, never half-applied. An actauth rule name has no coexistence option: two rules can't share one name in the sameactauth.yml, and there's no namespacing convention for rule names the way there is for tools/skills below.A tool or skill name collision is handled differently — namespaced under the ability's own name instead of refused outright, since both have somewhere to put the disambiguated name that a caller (the model) can still resolve:
- A tool name is what the model calls by, directly —
run-agent.ts's owndedupeToolsByNamekeeps only the last same-named entry and silently drops the rest before the model ever sees them (see that function's own doc comment), so two same-named tools could never actually both be callable without a name change. Ifagents/<agent>/tools/<name>.tsalready exists, this ability's own tool installs astools/<ability-name>__<name>.tsinstead (refusing only if even that is somehow already taken) — the file's own content is untouched (so a later upgrade's three-way merge still works unmodified), and only the generatedtools/index.tsis taught the disambiguated name, via a small wrapper object that overridesname(see step 4). - A skill id is only ever addressed through the
Skillmeta-tool's own argument, which already supports a<namespace>:<id>form (SkillGarden's existing nested-directory convention). Ifagents/<agent>/skills/<id>/already exists, this ability's own skill installs underagents/<agent>/skills/<ability-name>/<id>/instead (refusing only if even that is somehow already taken) — addressable as<ability-name>:<id>, no changes needed on the loading side at all.
- A tool name is what the model calls by, directly —
Write tool files, then patch
tools/index.ts— reusingaddToolToIndex(web/http-tool-admin.ts), called once per tool in the ability. For a plain (non-namespaced) tool this is exactly the bareimport { x } from './x.js'/[x]array-entry shape it's always been; for a namespaced one,addToolToIndexalso inserts oneconst <name>: ToolDefinition = { ...<aliasedImport>, name: '<ability-name>__<name>' }line between the import and the array — the only place the disambiguated model-facing name actually gets set.Copy skill directories into
agents/<agent>/skills/.Append actauth rules into
agents/<agent>/actauth.yml, under a generated comment marking which ability/version they came from (see next section — this comment is what upgrade/uninstall key off of).Record provenance in
agents/<agent>/.loopengine-abilities.json:{ "acme-order-tools": { "version": "1.0.0", "files": ["tools/get_order_onway.ts", "..."], "env": ["EM_ACCESS_TOKEN", "EM_BEARER_TOKEN"] } }Same role
.create-loopengine.jsonalready plays for template files — the merge base a futureupgradeneeds, the manifestuninstallneeds to know what's safe to remove, and (new) theenvlist is what the Admin UI reads to know which vars to prompt for — see next section. Installing doesn't write.envitself; it only registers that these names are now relevant.
Managing ability secrets in the Admin UI
Two things make this feature more sensitive than the rest of the admin surface, and the design accounts for both directly.
First: .env is already gitignored (both the main repo's and the
scaffold template's _gitignore), so writing secrets there is safe from
the "don't commit a token" angle — but LOOPENGINE_ADMIN_AUTH itself is
optional today, just a startup warning if unset
(adapters/http.ts:1443-1446 — "every route on this server ... is open
to anyone who can reach it"), not enforced. Letting the UI write raw
secret values makes an already-open deployment meaningfully worse than
today's gap (config/business data vs. actual credentials). So: this
specific feature refuses to run at all if LOOPENGINE_ADMIN_AUTH isn't
set — a hard requirement, not a warning, unlike the rest of the admin
surface.
Second: once set, a secret: true value is never echoed back — same
rule web/http-tool-admin.ts already established for a header value
referencing {{ENV_VAR}} (never persisted as a literal, never returned
through the admin API). The UI shows only set / not set status per
declared var, never the value.
Mechanically:
- The Admin UI reads every installed ability's
envlist (from every agent's.loopengine-abilities.json) and shows one row per declared var name: description, which ability(ies) declare it, and whetherprocess.env[name]currently has a value. More than one ability declaring the same name merges into that one row rather than hiding all but the first — see the "Open questions" section below for why that's surfaced, not resolved. - Submitting a new value does two things, not one: upserts the
KEY=VALUEline into the project's.envfile (a new small parse-and-upsert utility — preserve every other line/comment, replace or append the one key), and setsprocess.env[name]on the current running process immediately, so a newly-installed ability's tools work without a restart.
Upgrading
npx loopengine upgrade-ability acme-order-tools --agent customer-service
Per file the ability manages, the same three-way merge technique
create-loopengine upgrade already uses for template files
(threeWayMerge, git merge-file --diff3) — reimplemented here rather
than shared as code, since loopengine and create-loopengine are
separate published packages and a runtime depending on a scaffolding
tool (or vice versa) isn't a dependency direction worth introducing just
to share ~30 lines: "mine" is the file
currently in the agent's tree (possibly hand-edited since install),
"base" is the version recorded at the install/last-upgrade's own
published version (fetched via npm pack <package>@<old-version>,
same technique), "theirs" is the newly published version. A clean merge
writes through; a real conflict leaves <<<<<<< markers for a human,
identical to how a template-file upgrade conflict already surfaces
today — no new conflict-resolution UX to design.
actauth rules and skills upgrade the same way: a rule/skill unchanged between the recorded base and the new version is left alone; a hand-edit gets three-way-merged, not silently overwritten.
Uninstalling
npx loopengine remove-ability acme-order-tools --agent customer-service
Removes exactly the files .loopengine-abilities.json recorded —
refuses if any of them look hand-modified beyond what a normal upgrade
would have produced (same conflict-detection the upgrade path already
needs, reused here as a safety check rather than silently deleting
edited work).
Publishing an ability
No new tooling needed beyond loopengine.ability.json itself — an
ability is an ordinary npm package. npm publish from a directory
shaped as above is a complete, valid loopengine ability, whether that's
npm publish to the public registry, to a private one (a scoped
@company/pkg, GitHub Packages, a self-hosted Verdaccio), or just a
tagged commit in a private git repo with no registry involved at all —
npm pack accepts any of those as its target, not just a public
registry name, so loopengine add-ability/upgrade-ability take
whatever string the operator would already pass to npm pack
(@company/pkg, github:org/repo#v1.0.0, git+ssh://..., even
file:../local-path for testing) rather than assuming public npm. Auth
for a private target comes entirely from whatever .npmrc token or SSH
key/git-credential helper is already configured in the environment
running the command — no new auth system for loopengine itself to own.
One correction to the fetch step above: it's the same npm pack
command fetchPublishedTemplateDir already uses, generalized to a
caller-supplied spec instead of a hardcoded package name — that function
itself only ever fetches create-loopengine@<version>, so "already
handles git/npm sources" would overstate what it currently demonstrates;
accepting an arbitrary spec (public, private-registry, or git) is a
small but real generalization, not something already proven to work
today.
Explicitly out of scope for v1
- A public catalog/marketplace UI. The Admin UI's Skills tab used to
have a small bundled-skill catalog browser, removed once it became
clear a two-entry, hand-maintained registry wasn't earning its keep —
see
add-abilityinstall straight from a spec instead of a curated catalog. A real catalog/marketplace UI for abilities is a bigger, separate bet, not required to ship v1's install/upgrade mechanics. - Inter-ability dependencies. An ability can't declare "requires ability X installed first." Every ability is self-contained.
- Semver-range installs (
add-ability <package>@^1.0.0) — v1 always installs latest; ranges are a straightforward follow-on once the provenance-tracking above exists to check against. - Per-user enable/disable of an installed ability (Pi's own extension model supports this) — v1's install is binary, in the agent's tree or not; toggling without uninstalling is a real gap this spec doesn't attempt to close yet.
Open questions — not yet decided
- Should an ability be allowed to target more than one agent in a single
install (a monorepo with several agents that all want the same
tools), or is
--agentalways singular and a multi-agent install is just running the command more than once? actauthrule scope today is written relative to one agent (run-agent.tsappends/<agentName>automatically) — does an ability'sactauth/rules.ymlever need to express a scope narrower than "this whole agent," and if so, how does the manifest express that without the ability author needing to know the installing agent's name in advance?- Does
loopengineVersionneed to be checked againstactauth's own version too, given an ability's tool content could equally depend on an actauth feature that's version-gated? - There's one
.envper project, not per agent — if two abilities happen to declare the same env var name for an unrelated purpose, there is genuinely only one value for it, shared by both, whether that's actually correct or a coincidence. Unlike a tool or skill name collision, this can't be resolved by namespacing: an env var name is a literalprocess.env.Xreference baked into the ability's own code, not a label loopengine's generated glue controls, so there's nothing safe to rename here the way there is for those two. What the Admin UI does do:listDeclaredEnvVars(web/env-admin.ts) surfaces every ability that declares a given name instead of silently keeping only the first one seen — so the Environment tab's "Required by" column makes the overlap visible, even though it still can't tell a real conflict (two unrelated services, one ability's own var needs renaming) apart from an intentional shared credential. Still open: should the manifest format itself encourage/require a namespaced default (e.g. an ability's own name as an implicit prefix) rather than leaving collision-avoidance entirely up to each author picking a specific enough name?