Agent Framework

The agent framework provides a high-level Participant abstraction for building event-driven agents that participate in MACP coordination sessions. Instead of manually managing gRPC streams and protobuf encoding, you register handlers for message types and let the framework dispatch incoming messages. If you are new to MACP itself, the spec's Onboarding an Agent tutorial walks through the agent lifecycle independent of any SDK.

Quick Start

import { agent } from 'macp-sdk-typescript';

const participant = agent.fromBootstrap('./bootstrap.json');

participant
  .on('Proposal', async (msg, ctx) => {
    ctx.log('Received proposal', { option: msg.payload.option });
    await ctx.actions.evaluate({
      proposalId: msg.payload.proposalId,
      recommendation: 'approve',
      confidence: 0.9,
      reason: 'Meets criteria',
    });
  })
  .on('Evaluation', async (msg, ctx) => {
    await ctx.actions.vote({
      proposalId: msg.payload.proposalId,
      vote: 'approve',
      reason: 'Evaluation looks good',
    });
  })
  .onTerminal((result) => {
    console.log('Session resolved:', result.state);
  });

await participant.run();

Core Components

Participant

The central class that wraps a session, projection, transport, and dispatcher. Created via ParticipantConfig or the fromBootstrap() factory.

import { agent, MacpClient, Auth, MODE_DECISION } from 'macp-sdk-typescript';

const config: agent.ParticipantConfig = {
  participantId: 'agent-1',
  // Session ids must be a lowercase UUID v4/v7 or base64url (22+ chars) — a
  // UUID-shaped string is never reinterpreted as base64url (runtime's validator)
  sessionId: '550e8400-e29b-41d4-a716-446655440000',
  mode: MODE_DECISION,
  client: new MacpClient({ address: 'localhost:50051', auth: Auth.devAgent('agent-1') }),
  auth: Auth.devAgent('agent-1'),
  policyVersion: 'policy.fraud.majority-veto',
};

const participant = new agent.Participant(config);

(All agent-framework symbols live under the agent namespace export — the package does not expose a macp-sdk-typescript/agent subpath.)

Handler registration uses a fluent API:

  • .on(messageType, handler) — React to a specific message type (or '*' for all)
  • .onPhaseChange(phase, handler) — React when the projection phase changes (e.g., 'Voting', 'Committed')
  • .onTerminal(handler) — Called when the session reaches a terminal state, including a SessionCancel — no built-in projection maps SessionCancel to a terminal phase, so Participant recognizes it directly and dispatches { state: 'Cancelled' } even when no phase-driven path fires. Against the current macp-runtime, this fallback matters for processEvent() callers and cross-SDK parity with macp-sdk-python: the streamed run() loop never observes a SessionCancel from the runtime today (it's stored internally and surfaced instead via the session-lifecycle watch mechanism, not the envelope stream).

Handler Context

Every handler receives (message, ctx) where ctx provides:

interface HandlerContext {
  participant: { participantId, sessionId, mode };
  projection: { phase, transcript };
  actions: SessionActions;   // evaluate, vote, propose, commit, send, etc.
  session: SessionInfo;      // sessionId, mode, participants, policyVersion
  log: (msg, details?) => void;
}

fromBootstrap()

Factory that reads a JSON file containing session assignment details and creates a fully-configured Participant:

import { agent } from 'macp-sdk-typescript';

// Reads from file path
const p = agent.fromBootstrap('./bootstrap.json');

// Or from MACP_BOOTSTRAP_FILE env var
const p2 = agent.fromBootstrap();

Bootstrap payload format:

{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "participant_id": "agent-1",
  "mode": "macp.mode.decision.v1",
  "runtime_address": "localhost:50051",
  "auth_token": "optional-bearer-token",
  "agent_id": "optional-agent-id",
  "mode_version": "1.0.0",
  "policy_version": "policy.default",
  "secure": true,
  "allow_insecure": false,
  "participants": ["agent-1", "agent-2"]
}
FieldRequiredBehaviour
session_idyesLowercase UUID v4/v7 or base64url 22+ chars — a UUID-shaped string is never reinterpreted as base64url (runtime validator).
participant_idyesAuthenticated sender this agent will use.
modeyesOne of the five standard mode identifiers or an extension mode.
runtime_addressyesgRPC endpoint (host:port). Alias: runtime_url.
auth_tokennoBearer token. When present, the runner constructs Auth.bearer(auth_token, { expectedSender: participant_id }) so the SDK's identity guard rejects forged senders before any RPC.
agent_idnoOnly used when auth_token is absent, to drive Auth.devAgent(agent_id ?? participant_id). Since issue #124, Auth.devAgent also sets expectedSender to that same id, so a diverging agent_id now fails client-side on the first typed handler-driven action (e.g. acceptTask, vote) — not on the initiator's SessionStart/kickoff, which pass no explicit sender, and not on the generic send escape hatch, which still only fails server-side. The runtime's dev fallback authenticates the connection as the bearer value (agent_id), while those actions' sender is participant_id — the two must match, dev mode included.
securenoDefaults to true (TLS).
allow_insecurenoMust be true when secure is false; otherwise MacpClient throws.
mode_version, configuration_version, policy_versionnoVersion strings forwarded to the session helper.
participantsnoOptional participant roster, passed through to ParticipantConfig.
initiator.session_start.context_idnoUpstream context identifier (RFC-MACP-0007 (Decision Mode)). Forwarded to the mode-session start() as contextId.
initiator.session_start.max_suspend_msnoPer-session max-suspend cap in ms (proto ≥ 0.1.5). 0/absent = runtime default.
initiator.session_start.extensionsnoMap of extension metadata (RFC-MACP-0008 (Proposal Mode)). Wire-level extensions is a protobuf map<string, bytes>; per RFC-MACP-0001 §10.3, each value must be a base64-encoded string — the runner base64-decodes it (falling back to raw UTF-8 for a non-base64 string); a Buffer/Uint8Array value passes through unchanged for in-process callers. A number, boolean, plain object, array, or null value throws, as does a non-object extensions map itself.

Example: initiator with context + extensions

{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "participant_id": "agent-1",
  "mode": "macp.mode.decision.v1",
  "runtime_address": "localhost:50051",
  "auth_token": "<jwt>",
  "initiator": {
    "session_start": {
      "intent": "decide deployment",
      "participants": ["agent-1", "agent-2"],
      "ttl_ms": 30000,
      "context_id": "ctx-run-42",
      "extensions": {
        "aitp.tct": "eyJ0b2tlbiI6InQtYWJjIiwiaXNzdWVyIjoiaXNzLTEifQ=="
      }
    },
    "kickoff": {
      "message_type": "Proposal",
      "payload": { "proposalId": "p1", "option": "canary" }
    }
  }
}

The aitp.tct value above is the base64 encoding of {"token":"t-abc","issuer":"iss-1"} — encodeExtensions() base64-decodes it back to those raw bytes before they go on the wire as the protobuf bytes value.

Participant.run() emits SessionStart with both fields on the wire before dispatching the kickoff. Non-initiator agents receive them via the replay path described in the transport section below.

Strategies

Strategy objects are composable handler factories for common Decision Mode patterns. They decouple the decision logic from the handler wiring.

See docs/api/strategies.md for the full reference — the examples below are a tour, not an exhaustive list.

Evaluation Strategy

Automatically evaluates proposals:

import { agent } from 'macp-sdk-typescript';

const handler = agent.evaluationHandler(
  agent.functionEvaluator(async (proposal, session) => ({
    recommendation: proposal.option === 'deploy' ? 'approve' : 'reject',
    confidence: 0.85,
    reason: 'Automated evaluation',
  })),
);

participant.on('Proposal', handler);

Voting Strategy

Automatically votes after evaluations:

// Built-in: vote based on evaluation majority
participant.on('Evaluation', agent.votingHandler(agent.majorityVoter()));

// Custom threshold
participant.on('Evaluation', agent.votingHandler(
  agent.majorityVoter({ positiveThreshold: 0.8 }),
));

// Function form — no class needed
participant.on('Evaluation', agent.votingHandler(
  agent.functionVoter(
    (p) => p.evaluations.length >= 2,
    async () => ({ vote: 'APPROVE', reason: 'quorum of evaluations reached' }),
  ),
));

Commitment Strategy

Automatically commits when conditions are met:

participant.on('Vote', agent.commitmentHandler(
  agent.majorityCommitter({
    quorumSize: 3,
    action: 'deploy',
    authorityScope: 'production',
  }),
));

// Function form
participant.on('Vote', agent.commitmentHandler(
  agent.functionCommitter(
    (p) => (p.voteTotals()['APPROVE'] ?? 0) >= 2,
    async (p) => ({ action: 'decided', authorityScope: 'team', reason: `winner=${p.majorityWinner()}` }),
  ),
));

Cancel Callback

Long-running agents can expose a local HTTP endpoint that an orchestrator POSTs to in order to request shutdown (RFC-MACP-0001 (Core) §7.2 Option A). If the bootstrap JSON includes a cancel_callback block, Runner.fromBootstrap() starts the server on participant.run() and tears it down when run() exits:

{
  "cancel_callback": { "host": "127.0.0.1", "port": 47321, "path": "/cancel" }
}

For the full API — startCancelCallbackServer, CancelCallbackServer, CancelHandler, and Participant.attachCancelCallbackServer — see docs/api/cancel-callback.md. A runnable demo lives at examples/cancel-callback.ts.

Transports

Transports abstract how messages are received. Two built-in options:

GrpcTransportAdapter (default)

Uses MacpClient.openStream() for real-time bidirectional streaming. This is the default when no transport is specified.

On start, the adapter sends a subscribe-only frame (sendSubscribe(sessionId, this.lastSequence)) so the runtime replays accepted envelopes for the session from the adapter's own cursor before switching to live broadcast (RFC-MACP-0006 (Transport Bindings) §3.2). lastSequence is 0 before anything is delivered, so a fresh adapter's first start() still replays the whole session — that is what lets a non-initiator Participant attach to a session at any point and still see the SessionStart + prior Proposal / Vote / … envelopes, regardless of spawn order or connection timing. A second start() on the same adapter (after stop()) resumes from where the first pass left off instead of replaying from the beginning; the adapter tracks that cursor by counting distinct accepted envelopes (keyed on message_id), not raw deliveries, so a redelivered envelope does not advance it. That protection is scoped to the cursor only, not to your code: dispatch to user-registered handlers (Participant.on, src/agent/participant.ts:345) has no message_id filter of its own, so a redelivered envelope still reaches your handlers a second time and handler logic needs to be idempotent on its own terms.

Note that this adapter does not reconnect on its own: there is no built-in retry or backoff. Participant.run() iterates the adapter's stream once and does not catch or retry a transport error — the reachable "reconnect" is a caller invoking start() again, directly or after stop().

HttpTransportAdapter

Polls an HTTP endpoint for events. Useful when gRPC is not available:

import { agent } from 'macp-sdk-typescript';

const transport = new agent.HttpTransportAdapter({
  baseUrl: 'http://localhost:3000',
  sessionId: 'session-123',
  participantId: 'agent-1',
  pollIntervalMs: 1000,
  authToken: 'optional-token',
});

const participant = new agent.Participant({
  ...config,
  transport,
});

Mode Support

The Participant automatically creates the correct session and projection for all 5 standard modes:

ModeActions Available
Decisionevaluate, vote, raiseObjection, propose, commit, send
Proposalpropose, commit, send
Taskcommit, send
Handoffcommit, send
Quorumcommit, send

For extension modes, a fallback DecisionProjection is used and only send is available.

Lifecycle

  1. Create — new agent.Participant(config) or agent.fromBootstrap()
  2. Register handlers — .on(), .onPhaseChange(), .onTerminal()
  3. Run — await participant.run() starts consuming messages
  4. Terminal — When the projection reaches a terminal phase (Committed, Accepted, Declined, Cancelled, or TerminalRejected), the terminal handler fires and run() returns
  5. Stop — Call await participant.stop() to stop early