Agent extension quickstart
Create an installable package that reports an agent's live sessions to Terminay. The example agent writes one small JSON file per running process; replace that part with however your agent records its sessions.
Before you start
You need Node 24 or later, npm, and a Terminay Server where you can manage extensions.
1. Declare the session source
Create package.json at the package root.
{
"name": "my-terminay-agent",
"version": "0.1.0",
"type": "module",
"exports": { ".": "./extension.js" },
"files": ["extension.js"],
"peerDependencies": { "@terminay/extension-api": "^3.0.0" },
"devDependencies": { "@terminay/extension-api": "^3.0.0" },
"terminay": {
"manifestVersion": 1,
"id": "com.example.my-agent",
"displayName": "My agent",
"api": "^3.0.0",
"engines": { "terminay": ">=1.0.0", "node": ">=24" },
"entrypoint": "extension.js",
"platforms": ["darwin", "linux"],
"permissions": ["agent-observation"],
"contributes": {
"agentSessionSources": [{
"id": "com.example.my-agent/sessions",
"displayName": "My agent",
"harnesses": [{ "id": "my-agent", "displayName": "My agent" }],
"environmentVariables": ["MY_AGENT_HOME"]
}]
}
}
}- The
terminayobject is the extension manifest. Its IDs are stable public identities, so choose a reverse-domain prefix you control. - The entrypoint must appear in
exports. - A session source requires the
agent-observationpermission and at least one harness. environmentVariablesnames server environment variables the extension process should receive. Other variables are not inherited.
2. Register the runtime
Create extension.js next to it.
import { watch } from 'node:fs';
import { mkdir, readdir, readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import {
boundedAgentText,
defineExtension,
defineSessionSource,
EXTENSION_LIMITS,
} from '@terminay/extension-api';
// This agent keeps one <pid>.json file per live process under <home>/sessions/
// and deletes it when the process exits.
const home = () => process.env.MY_AGENT_HOME || join(homedir(), '.my-agent');
async function readSession(directory, name) {
const match = /^(\d+)\.json$/.exec(name);
if (!match) return undefined;
try {
const record = JSON.parse(await readFile(join(directory, name), 'utf8'));
if (typeof record.id !== 'string' || typeof record.cwd !== 'string')
return undefined;
return {
id: record.id,
harness: 'my-agent',
pid: Number(match[1]),
cwd: record.cwd,
title: boundedAgentText(record.title, EXTENSION_LIMITS.agentTitleLength),
status: ['running', 'waiting', 'idle'].includes(record.status)
? record.status
: undefined,
};
} catch {
return undefined; // Mid-write or already gone: the next watch event settles it.
}
}
const source = defineSessionSource({
async start({ enabledHarnesses, publisher, signal, onEnabledHarnessesChanged }) {
const directory = join(home(), 'sessions');
await mkdir(directory, { recursive: true });
let enabled = enabledHarnesses.includes('my-agent');
const live = new Map(); // file name -> session id
async function resetAll() {
live.clear();
if (!enabled) return publisher.reset([]);
const sessions = [];
for (const name of await readdir(directory)) {
const session = await readSession(directory, name);
if (!session) continue;
live.set(name, session.id);
sessions.push(session);
}
if (!signal.aborted) publisher.reset(sessions);
}
// Watch, never poll. Each event re-reads only the file it names.
watch(directory, { signal }, async (_event, name) => {
if (!enabled || typeof name !== 'string') return;
const session = await readSession(directory, name);
if (signal.aborted) return;
if (session) {
live.set(name, session.id);
publisher.upsert(session);
} else if (live.has(name)) {
publisher.remove(live.get(name));
live.delete(name);
}
});
const subscription = onEnabledHarnessesChanged((next) => {
enabled = next.includes('my-agent');
void resetAll();
});
signal.addEventListener('abort', () => subscription.dispose(), { once: true });
await resetAll();
},
});
export default defineExtension({
activate(context) {
context.subscriptions.add(
context.agents.registerSessionSource('com.example.my-agent/sessions', source),
);
},
}); start() resolves once the source is running and keeps publishing until
signal aborts. Send a reset after starting and after every change
to the enabled harnesses, then upsert and remove individual
sessions as they change.
3. Validate, pack, and install
mkdir my-terminay-agent && cd my-terminay-agent
# Add package.json and extension.js from this guide.
npm install
npx terminay-extension-conformance package.json
npm pack- In Terminay, open Settings → Extensions for the target server.
- Under Install package file, choose the generated
.tgz. - Review the package identity, requested permission, and trusted-code warning, then confirm.
Terminay validates the archive and starts the package in its own extension host process. It never runs install scripts. A live session then appears in a project's Agents panel when its working directory is inside that project.
Next
- Session source reference: snapshot fields, bounds, and publication rules.
- Add an MCP install target for the same agent.
- Test the source with the in-memory harness.