Both tools and system prompt sections are provided to the Pi Harness via extensions. An extension is Pi Durable's own: a plain object with a name, tools, and sections, installed on the registry you pass to Harness.open(). PiHarness does not wrap them, so every Pi Durable extension feature works.
Create the registry with createRegistry() and install extensions in the harness factory, before Harness.open():
import { Agent } from "agents";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { PiHarness } from "agents/harness/pi";
import { createAI } from "agents/models/pi-ai";
const WordCount = Type.Object({ text: Type.String() });
const wordCount = {
name: "word_count",
description: "Count the words in a text.",
parameters: WordCount,
replay: "safe",
// `text` is typed from `parameters`, which Pi validates first.
async execute({ text }) {
const words = text.split(/\s+/).filter(Boolean).length;
return { content: [{ type: "text", text: String(words) }] };
},
};
export class Editor extends Agent {
ai = createAI({ binding: this.env.AI });
registry = createRegistry();
harness = new PiHarness({
harness: ({ storage, context }) => {
this.registry.install({
name: "editor",
sections: [
{ key: "preamble", render: () => "You are an editor.", tag: false },
],
tools: [wordCount],
});
const models = createModels();
models.setProvider(this.ai.provider);
return Harness.open(
storage,
{ models, registry: this.registry },
context,
);
},
defaults: { model: this.ai("@cf/moonshotai/kimi-k2.7-code") },
});
constructor(ctx, env) {
super(ctx, env);
this.lifecycle.use(this.harness);
}
}import { Agent } from "agents";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import {
createRegistry,
Harness,
type ToolRegistration,
} from "@earendil-works/pi-durable";
import { PiHarness } from "agents/harness/pi";
import { createAI } from "agents/models/pi-ai";
const WordCount = Type.Object({ text: Type.String() });
const wordCount: ToolRegistration<typeof WordCount> = {
name: "word_count",
description: "Count the words in a text.",
parameters: WordCount,
replay: "safe",
// `text` is typed from `parameters`, which Pi validates first.
async execute({ text }) {
const words = text.split(/\s+/).filter(Boolean).length;
return { content: [{ type: "text", text: String(words) }] };
},
};
export class Editor extends Agent<Env> {
ai = createAI({ binding: this.env.AI });
registry = createRegistry();
harness = new PiHarness({
harness: ({ storage, context }) => {
this.registry.install({
name: "editor",
sections: [
{ key: "preamble", render: () => "You are an editor.", tag: false },
],
tools: [wordCount],
});
const models = createModels();
models.setProvider(this.ai.provider);
return Harness.open(
storage,
{ models, registry: this.registry },
context,
);
},
defaults: { model: this.ai("@cf/moonshotai/kimi-k2.7-code") },
});
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.lifecycle.use(this.harness);
}
}The factory runs once per isolate, inside the object's startup, so each extension is installed once. registry.install() replaces an installed extension with the same name.
A tool is a Pi Durable ToolRegistration:
- Typed arguments. Type a tool as
ToolRegistration<typeof Parameters>, andexecutegets arguments typed from the schema. Pi validates them againstparametersbefore it callsexecute. A bareToolRegistrationtypes its arguments asunknown. - What a running call can use.
execute(args, api, context)gets Pi's operations for the call.api.output()streams running output,api.details()sends data for a UI, andapi.memo()stores values that survive an eviction. - Abort.
context.abortSignalis aborted when the call is. Honor it:session.abort()waits until every running tool returns. - Parallel calls. Pi runs the tool calls in one model round at the same time by default. Set
executionMode: "sequential"on a tool whose calls must run one after another. One sequential call makes its whole round sequential.
const FetchPage = Type.Object({ url: Type.String() });
const fetchPage: ToolRegistration<typeof FetchPage> = {
name: "fetch_page",
description: "Fetch a web page as text.",
parameters: FetchPage,
replay: "safe",
async execute({ url }, api, context) {
api.output(`fetching ${url}\n`);
const response = await fetch(url, { signal: context.abortSignal });
return { content: [{ type: "text", text: await response.text() }] };
},
};When an eviction interrupts a tool call, Pi decides what to do from the tool's replay setting:
replay: "safe": Pi runs the tool again after the object restarts.replay: "unsafe", the default: Pi does not run it again. The model gets an interrupted result and decides what to do next.
Mark a tool safe when running it twice does no harm, such as a read, a search, or a whole-file write. Leave it unsafe when a second run would fail or repeat a side effect, such as an edit of text it already replaced, running code, or sending an email.
A section's render runs before each model request. It receives the conversation, its agent and offered tools, and committed reads, so a section can change from one request to the next. Pi wraps the output in <key> tags unless tag is false:
this.registry.install({
name: "context",
sections: [
{ key: "preamble", render: () => "You are an editor.", tag: false },
{ key: "today", render: () => new Date().toDateString() },
],
});skills(sources) from agents/harness/pi turns Agent Skills sources from agents/skills into a Pi extension named agents.skills. It has two tools, activate_skill and read_skill_resource, and a skills section that lists the skills. It reads the sources, so install it in an async factory:
import { skills } from "agents/harness/pi";
import { fromManifest } from "agents/skills";
const handbook = fromManifest({
id: "handbook",
fingerprint: "v1",
skills: [
{
name: "release-notes",
description: "Write release notes.",
body: "One line per change.",
},
],
});
harness = new PiHarness({
harness: async ({ storage, context }) => {
this.registry.install(await skills([handbook]));
// ...models, then Harness.open()
},
});The tools match the ones Think offers, so a skill written for one works in the other. The model can read a skill's instructions and resources. Skill scripts do not run.
Because the registry is Pi's own, everything else Pi Durable extensions can do also works:
- Hooks on model requests, tool calls, and compaction, such as
hook(ToolTask, { beforeTool }). - Durable custom tasks that resume after a restart.
- Wrapping another extension's tools and sections.
- Choosing extensions per conversation.
- Extension state in typed documents.
For each, refer to the Pi Durable README ↗︎.