Extensions / Guides

Build an agent provider

An agent provider recognizes one foreground CLI, proves a session belongs to that terminal, then translates its journal into Terminay lifecycle events.

What you will build

This minimal provider recognizes acme-agent, follows its current JSONL journal, and publishes a turn when it sees a prompt record. It observes an existing CLI; it does not start, drive, or send input to that CLI.

1. Declare the integration

Put the Terminay manifest in your package’s terminay field. The contributed provider ID must match the ID passed to registerProvider().

JSON
{
  "name": "acme-terminay-agent",
  "version": "0.1.0",
  "type": "module",
  "dependencies": {
    "@terminay/extension-api": "^1.2.0"
  },
  "terminay": {
    "manifestVersion": 1,
    "id": "com.acme.agent",
    "displayName": "Acme Agent",
    "api": "^1.2.0",
    "engines": { "terminay": ">=1.0.0", "node": ">=20" },
    "entrypoint": "dist/index.js",
    "permissions": ["agent-observation"],
    "contributes": {
      "agentProviders": [{
        "id": "com.acme.agent/cli",
        "displayName": "Acme Agent",
        "processMatchers": [{ "executableName": "acme-agent" }],
        "requiredEnvironmentCapabilities": [
          "filesystem-observation",
          "agent-journal"
        ],
        "mappings": [{
          "mappingVersion": "1",
          "providerVersionRange": ">=1 <2"
        }]
      }]
    }
  }
}
  • agent-observation authorizes terminal-scoped observation and canonical event publication.
  • processMatchers are exact, safe CLI identification hints—not shell commands or regular expressions.
  • Request only the environment capabilities your mapping needs. A provider must handle their absence.

2. Register and observe

Keep matchesForeground() fast and side-effect-free. Put journal discovery and session proof in observe(), where the host gives you a terminal-scoped context.

TypeScript
import {
  defineAgentProvider,
  defineExtension,
  jsonlSession,
} from "@terminay/extension-api";

const provider = defineAgentProvider({
  mappingVersion: "1",

  matchesForeground(process) {
    return process.executableName === "acme-agent";
  },

  async observe(terminal) {
    if (!terminal.capabilities.has("agent-journal")) {
      return { state: "unavailable", reason: "environment-capability-missing" };
    }

    const journal = await terminal.observation.files.resolveHomeRelative(
      ".acme-agent/sessions/current.jsonl",
      { beneath: { homeRelative: ".acme-agent/sessions" }, extension: ".jsonl", signal: terminal.signal },
    );
    if (!journal) return { state: "not-bound" };

    const header = await terminal.observation.files.readJsonLine(journal, {
      position: "first", maxBytes: 64 * 1024, signal: terminal.signal,
    });
    if (!isSessionHeader(header)) return { state: "not-bound" };

    const binding = await terminal.bindSession({
      providerSessionId: header.id,
      mappingVersion: "1",
      journal,
      fingerprint: { kind: "known-agent-journal", file: journal },
    });

    return jsonlSession({
      binding,
      source: terminal.observation.files.follow(journal, { signal: terminal.signal }),
      mapRecord(record, session) {
        if (isRecord(record) && record.type === "prompt" && typeof record.id === "string") {
          session.publish.turnStarted({ turnId: record.id, promptText: asText(record.text) });
        }
      },
    });
  },
});

export default defineExtension({
  activate(context) {
    context.subscriptions.add(
      context.agents.registerProvider("com.acme.agent/cli", provider),
    );
  },
});

function isRecord(value: unknown): value is Record<string, unknown> {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

function isSessionHeader(value: unknown): value is { id: string } {
  return isRecord(value) && typeof value.id === "string";
}

function asText(value: unknown): string | undefined {
  return typeof value === "string" ? value.slice(0, 4_000) : undefined;
}

Observation outcomes

Return valueUse it when
{ state: "bound" }Your provider found and bound a verifiable session. jsonlSession() returns this form.
{ state: "not-bound" }The CLI is recognized but no safe, current session can be established.
{ state: "unavailable", reason }A required environment capability is missing or the provider cannot safely observe this terminal.

Use not-bound for an ordinary miss. Use unavailable only with a documented reason such as environment-capability-missing, unsupported-provider-version, or malformed-observation.

Safe boundaries

File and process values are opaque, terminal-scoped handles. A display path is not read authority; use the observation broker for every read and follow. The API does not provide a PTY, shell, raw terminal stream, renderer UI, or direct access to Terminay’s agent store.

Next: observe JSONL journals or publish lifecycle events.