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.

JSON
{
  "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 terminay object 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-observation permission and at least one harness.
  • environmentVariables names server environment variables the extension process should receive. Other variables are not inherited.

2. Register the runtime

Create extension.js next to it.

TypeScript
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

Shell
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
  1. In Terminay, open Settings → Extensions for the target server.
  2. Under Install package file, choose the generated .tgz.
  3. 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