@loopingai/plugins
v0.2.1
Published
Optional, composable capabilities for Looping agents: ARC-AGI-3, browser rendering, workspace, episodic recall, and pre-turn triage. One subpath per plugin, so a bundle grows only with what it imports.
Downloads
340
Maintainers
Readme
@loopingai/plugins
Optional, composable capabilities for Looping agents.
One subpath per plugin, one factory per subpath, config passed at instantiation. Your bundle grows only with what you import.
npm install @loopingai/pluginsPart of a three-package split:
@loopingai/core(the mandatory foundation) ·@loopingai/plugins(this) ·looping-starter(a working agent that composes them).
The one file you edit
// src/plugins.ts
import { arcAgi } from "@loopingai/plugins/arc-agi";
import { browser } from "@loopingai/plugins/browser";
import { recall } from "@loopingai/plugins/recall";
export interface PluginHost {
env: Env;
storage: DurableObjectStorage;
/** The verified caller this Durable Object belongs to. See below. */
callerKey: () => string;
}
export const plugins = ({ env, storage, callerKey }: PluginHost) => [
arcAgi({ apiKey: env.ARC_API_KEY, storage }),
browser({ binding: env.BROWSER }),
recall({ ai: env.AI, index: env.VECTORIZE, namespace: callerKey })
];Delete a line and that module leaves your bundle entirely. Nothing in core imports a plugin,
and there is no root barrel — @loopingai/plugins on its own does not resolve — so the
guarantee is structural rather than a tree-shaker's opinion. npm run verify:exports asserts
it on the built graph before every publish.
plugins is a function, not a module-level array: on Workers env does not exist at module
scope, and core's registry is built per Durable Object instance in onStart().
export class MyAgent extends Agent<Env> {
/** Set on the first verified request; constant thereafter. See below. */
private identity?: string;
async onStart() {
this.runtime = createAgentRuntime({
config,
plugins: plugins({
env: this.env,
storage: this.ctx.storage,
// A thunk, not a value: `onStart` runs before any request, so the caller
// is not known yet. The DO is keyed 1:1 by that caller, so it is constant
// once it is — this just defers reading it until it exists.
callerKey: () => this.identity ?? ""
}),
env: this.env // verify every plugin's declared bindings exist, at startup
});
}
async onTurn(turn: AgentTurn, identity: GatewayIdentity) {
this.identity ??= identity.key!;
// …
}
}That deferral is the whole reason /recall takes namespace as a function. Anything else
needing per-caller state takes it the same way.
The plugins
| Subpath | What it adds | Needs |
| ------------------------------ | ------------------------------------------------------------------------------------- | ----------------------------- |
| /arc-agi | Play ARC-AGI-3 games — a delegable subtask type, a catalogue tool, a scorecard ledger | ARC_API_KEY |
| /browser | Read web pages via Browser Rendering Quick Actions | BROWSER (paid plan) |
| /recall | Episodic memory over Vectorize — search history that compaction folded away | VECTORIZE (1024-dim/cosine) |
| /triage | A pre-turn gate: is this message even for me? | — |
| /workspace | A durable file store for long subagent runs, plus tools over it | @cloudflare/shell |
Each directory has its own README with the config shape and a paste-ready wrangler.jsonc
snippet — a plugin cannot add its own binding, which is why it declares what it needs.
Writing one
import { definePlugin, type AgentPlugin } from "@loopingai/core";
export function scraper(config: { apiKey: string }): AgentPlugin {
return definePlugin({
key: "scraper",
mainAgentTools: () => ({ fetchPage: /* … */ }),
capability: "You can fetch and summarize a page.",
requires: { secrets: ["SCRAPER_API_KEY"] }
});
}Three rules the whole design rests on:
- Never name a consumer's
Env. It is an ambient interfacewrangler typesgenerates into their app. Take bindings and secrets as config, which is also the only thing that works on Workers, whereenvhas no module scope. definePluginsetscontractVersionfrom the core you compiled against. Never write that number as a literal — the point is that it moves, so a version train that leaves one repo behind fails at startup with a sentence instead of a structural-type error.- Declare a capability block in exactly one place. If your plugin has a
subtaskType, put it there; otherwise on the plugin. Both are rendered, by different call sites.
Testing
Specs run inside real workerd via @cloudflare/vitest-pool-workers, with the harness from
@loopingai/core/testing.
npm test # 231 specs, no credentials and no network
npm run check # prettier + eslint + tsc + build
npm run verify:exportstest/arc-agi/recorded.spec.ts drives the real ARC API and replays a committed
cassette, so it needs no key either. Re-record it against the live API with:
npm run test:record # real ARC_API_KEY in .env.test (see .env.test.example)The key reaches the live ARC API and nothing else: the recorder excludes the auth header, so
it never lands in the committed cassette. It reaches the spec only as a Miniflare binding —
.env.test is loaded into Node's process.env, which a spec running in workerd cannot
see, so vitest.config.ts hands it across explicitly.
License
GPL-3.0-only
