Skip to content

Core Concepts

Act

The fifth phase of the Pluck pipeline. Every mutation produces an Ed25519-signed receipt. Reversible when the actor declares an inverse. Policy-gated, idempotent, dry-run by default on the MCP surface.


The mental model

Reading is safe. Writing is not.

The act phase exists because the 2026 agent landscape has a credibility problem: agents call real APIs, move real money, post real messages, and nobody can explain what the agent actually did after the fact. Pluck solves this with four primitives that compose at runtime:

  1. Signed receipts – every action produces an Ed25519-signed, canonicalised record you can verify without Pluck.
  2. Undo – actors declare an inverse operation. pluck.undo(receipt) reverses what just happened.
  3. Policy.pluckpolicy.yaml evaluates against every action preview before execution. Deny-list for "never delete from prod."
  4. Idempotency – every action has a stable key. Repeated calls with the same key return the cached receipt instead of re-executing.

None of these are opt-in plumbing. They're always on; dry-run is the default for MCP-originated calls; signing keys are auto-generated if you don't supply them.

Act is a phase, not a standalone verb. The top-level act() convenience wrapper and pluck.act() instance method both drive the same pipeline – connect → navigate → act – and both return a PluckResult with the signed receipt attached.

TypeScript
import { act } from "@sizls/pluck";

const result = await act("https://api.example.com/todos", {
  action: "post",
  input: { title: "Buy milk" },
});

// result.receipt is the actor-specific body (HTTP response, shell output, etc.)
console.log(result.receipt);
// { status: 201, body: { id: "abc" } }

// result.signedReceipt is the full Ed25519-signed envelope – this is what
// you archive, verify, and pass to pluck.undo / verifyChain.
console.log(result.signedReceipt);
// {
//   version: 1,
//   action: "post",
//   source: "https://api.example.com/todos",
//   idempotencyKey: "...",
//   inputHash: "sha256:...",
//   receipt: { status: 201, body: { id: "abc" } },
//   dryRun: false,
//   timestamp: "2026-04-19T22:15:00Z",
//   signedBy: "9f3a8b1c...",          // first 32 hex chars of sha256(publicKey)
//   signature: "..."                   // Ed25519, base64
// }

Built-in actors

Nine actors ship today. Every actor follows the same interface – match, actions, act – so registering your own is a 20-line exercise (see below).

#ActorMatchesActions
1graphqlURIs whose path contains /graphql or graphql://query, mutate
2http-restany http:// / https:// URIpost, put, patch, delete
3browserany http:// / https:// URI (Playwright peer required)navigate, click-flow, fill-and-submit, extract-after-interact, upload, download
4browser-agentany http:// / https:// URI (LLM-driven)agent-navigate (reserved action name – see Reference: Actors)
5shell-writeshell-write: URIsfs:write-file, fs:append, fs:make-dir, exec:command
6emailmailto: / smtp:// / smtps:// (nodemailer peer)send, send-with-attachment
7awsaws://<service>/... (lazy @aws-sdk/client-* peers)s3:put-object, s3:delete-object, dynamodb:put-item, sqs:send-message, sns:publish, lambda:invoke
8gcpgcp://<service>/... (lazy @google-cloud/* peers)storage:put-object, pubsub:publish, firestore:write-doc, functions:call
9azureazure://<service>/... (lazy @azure/* peers)blob:put, servicebus:send, cosmos:put-item, functions:invoke

Dispatch is action-name-aware: a URL that matches multiple actors (e.g. https://example.com matches http-rest, browser, and browser-agent) resolves to the actor whose actions() includes the requested action name. Unknown actions fail fast with NO_ACTOR_FOR_ACTION and a list of available actions for the URI.

Registry introspection on any live instance:

TypeScript
pluck.actors.list();
// ["graphql", "http-rest", "browser", "browser-agent",
//  "shell-write", "email", "aws", "gcp", "azure"]

pluck.actors.findForAction("https://api.example.com/graphql", "mutate");   // "graphql"
pluck.actors.findForAction("https://api.example.com/items", "post");       // "http-rest"
pluck.actors.findForAction("https://example.com", "navigate");             // "browser"
pluck.actors.findForAction("https://example.com", "agent-navigate");       // "browser-agent"
pluck.actors.findForAction("aws://s3/bucket/key", "s3:put-object");        // "aws"
pluck.actors.findForAction("mailto:alerts@x.com", "send");                 // "email"

All nine actors strip credentials from their output receipts before signing. The Authorization header never appears in a receipt. Payloads that contain fields named password, token, secret, apiKey, etc. are run through sanitizeForLogging before landing in receipt.input. Cloud functions receipts omit the response body entirely – a reflector function could otherwise echo the bearer token into a signed audit record. Email receipts store a 16-char SHA-256 hash of the subject rather than the subject itself.

The browser-agent actor adds a 4-layer response-policy gate on top of the standard policy rails – allowedDomains, allowedActions, actionBudget, humanInLoop. See Security for the full policy shape, halt codes, and safe-defaults template.


Act vs. Navigate – when to reach for which

Two of the built-in actors (browser, browser-agent) look a lot like the navigate phase's interact / agent / navigate / screenshot modes – both load URLs, click things, return data. They are not redundant:

  • Navigate modes read. pluck(URL, { mode: "interact", steps }) dismisses a modal so extract can read the content behind it. No receipt, no policy gate, no idempotency key. Re-running is free.
  • Act actors write. pluck.act(URL, { action: "fill-and-submit", input }) submits a form. Every call emits a signed receipt, runs the policy engine, and honours the idempotency key – repeats return the cached receipt instead of re-submitting.

The smell test: "do I want to be able to undo this, explain what happened afterwards, or prevent this from running in prod?" If yes → act. Otherwise → navigate. See the Navigate page for a side-by-side table.


Signed receipts

Every act call produces a SignedReceipt:

TypeScript
interface SignedReceipt {
  version: 1;                  // Format version – bumps on breaking changes
  action: string;              // "post" / "put" / "delete" / "mutate" / ...
  source: string;              // The URI acted on
  idempotencyKey: string;      // Stable key – explicit or derived
  inputHash: string;           // sha256 of canonicalised input (credential-scrubbed)
  receipt: unknown;            // Actor-specific body (status, response JSON, etc.)
  dryRun: boolean;             // True when the action was previewed, not executed
  timestamp: string;           // ISO 8601
  signedBy: string;            // sha256 fingerprint of the public key (first 32 hex chars / 128 bits)
  signature: string;           // Ed25519 over the canonicalised body
  parentSig?: string | string[]; // Signature(s) of upstream receipts this one depends on
}

Verification is standalone – you don't need Pluck to verify a receipt, just the public key:

TypeScript
import { verifyReceipt } from "@sizls/pluck";

const ok = verifyReceipt(signedReceipt, publicKeyPem);
// true | false – no side effects, no network

Every live instance also exposes the verification and keygen helpers via pluck.receipts:

TypeScript
pluck.receipts.verify(result.signedReceipt!, publicKeyPem);
pluck.receipts.generateKeys();       // { publicKey, privateKey } PEM strings
pluck.receipts.fingerprint(publicKeyPem); // 32-hex-char identity (first 128 bits of sha256)
pluck.receipts.sign(unsignedReceipt);     // uses the instance signing key

Keys default to a runtime-generated ephemeral pair. For production you want durable keys – read the private key from the environment so restarts don't rotate the signer:

TypeScript
import { createPluck } from "@sizls/pluck";

const pluck = createPluck({
  signingKey: process.env.PLUCK_SIGNING_KEY, // PEM-encoded Ed25519 private key
});

An ephemeral pair (no PLUCK_SIGNING_KEY set) is fine for local experiments, but receipts signed by a fresh key on every process start cannot be verified across deploys – nothing holds the public half. Set the env var and commit the matching public key to the repo.

Generating your keys

Generate a durable Ed25519 keypair once with the CLI and stash the private key in your secret manager:

Shell
pluck keys generate --name pluck --dir ./keys
# Writes ./keys/pluck.pem (private) and ./keys/pluck.pub.pem (public)

Commit pluck.pub.pem to the repo so anyone can verify receipts offline. Load pluck.pem into PLUCK_SIGNING_KEY through your secret manager (Vercel / Doppler / AWS Secrets Manager / 1password run, etc.).

Canonicalisation is a key-sorted JSON.stringify (stable numbers, UTF-8 NFC). It is not JCS (RFC 8785); third-party verifiers should replicate Pluck's scheme by sorting keys recursively and using default JSON number formatting. See packages/core/src/act/receipts/sign.ts for the source of truth.

How receipts chain

SignedReceipt.parentSig links each receipt to the signatures of the receipts whose output fed it. Worked example – an extract followed by an act against the same URI:

Step 1 – pluck.extract() returns a signed extract receipt:

JSON
{
  "version": 1,
  "action": "extract",
  "source": "https://api.example.com/todos",
  "receipt": { "items": [{ "id": 41, "title": "Buy milk" }] },
  "timestamp": "2026-04-21T14:02:11Z",
  "signedBy": "9f3a8b1c...",
  "signature": "Base64(Ed25519 over canonical body)"
}

Step 2 – pluck.act() posts a new todo and references the extract receipt in parentSig:

JSON
{
  "version": 1,
  "action": "post",
  "source": "https://api.example.com/todos",
  "receipt": { "status": 201, "body": { "id": 42 } },
  "parentSig": "Base64(Ed25519 over canonical body)",
  "timestamp": "2026-04-21T14:02:12Z",
  "signedBy": "9f3a8b1c...",
  "signature": "Base64(Ed25519 over canonical body)"
}

parentSig is part of the canonical body, so altering it invalidates the act receipt's own signature. It can be a single string or an array – one act can reference multiple upstream receipts (e.g. extract + shape).

This is signature-chaining – a parent-link Ed25519 graph. It is weaker than a Merkle tree in one respect: if a signing key is compromised, an attacker can re-sign with any parent reference. For strong append-only provenance, pair Pluck with the DSSE-signed driftwatch audit log.

Step 3 – pluck verify-chain walks the pair:

Shell
pluck verify-chain ./extract-42.json ./act-42.json --pub-key ./keys/pluck.pub.pem

The verifier checks every Ed25519 signature, then matches each parentSig against the signature field of another receipt in the input set. Missing parents fail strict mode; tampered parents fail signature verification. Either way the exit code is non-zero and the act receipt is rejected.

TypeScript
interface SignedReceipt {
  // ... other fields
  signature: string;               // this receipt's signature
  parentSig?: string | string[];   // signatures of upstream receipts
}

Populate parentSig by passing it on the unsigned receipt before the signing step; the pipeline wires it automatically when you chain pluck.extract()pluck.act() against the same URI within a single session.

pluck verify-chain – the chain-of-custody primitive

pluck verify-chain is the CLI half of the attestation story. Pass it any number of receipt files (or a directory of .pluckreceipt.json / .receipt.json / .json files) plus the public key(s) used to sign them. It verifies every Ed25519 signature, cross-references every parentSig against the other receipts in the input set, and exits non-zero on any failure.

Shell
# Verify a single act receipt
pluck verify-chain ./order-42.receipt.json --pub-key ./keys/prod-pub.pem

# Verify a full chain across multiple receipts
pluck verify-chain \
  ./connect-42.receipt.json \
  ./extract-42.receipt.json \
  ./shape-42.receipt.json \
  ./act-42.receipt.json \
  --pub-key ./keys/prod-pub.pem

# Walk every receipt in a directory
pluck verify-chain ./receipts/ --pub-key ./keys/prod-pub.pem

# Multi-key rotation
pluck verify-chain ./receipts/ \
  --pub-key ./keys/2026-pub.pem \
  --pub-key ./keys/2025-pub.pem

# Strict mode – fail if any parentSig references a receipt not in the input set
pluck verify-chain ./receipts/ --pub-key ./keys/prod-pub.pem --strict

# Machine-readable report for CI / SIEM ingestion
pluck verify-chain ./receipts/ --pub-key ./keys/prod-pub.pem --json

Exit codes: 0 – every receipt verified and every parent resolved (in strict mode). 1 – at least one receipt failed. 2 – usage error (missing paths, unreadable key).

Programmatic equivalent – verifyChain(receipts, { publicKeys, strict }) from @sizls/pluck returns a ChainVerificationResult with per-receipt checks, a missingParents list, and a renderable summary. Use it in CI to gate promotions on receipt integrity.

The chain verifier also runs cycle detection on the parent graph, so a hand-fabricated audit trail that loops back on itself is rejected even if each individual signature verifies.


Undo

Undo works when the actor declares an inverse action. The shipped HTTP-REST actor does not today (all four methods – post, put, patch, delete – are reversible: false); custom actors can declare inverses and immediately unlock pluck.undo().

reversible: true means the action is safe to replay or has no destructive effect to undo. It is not the same as pluck.undo() working – that needs an inverse action too. The table below separates the two:

Actorreversible: true?Has inverse action?pluck.undo() works?
http-restNo – all four methods (post, put, patch, delete) are reversible: falseNoNo
graphqlquery yes (read-safe), mutate noNoNo – query is read-only so there's nothing to undo
browserNo – all 6 actions scripted for forward-only interactionNoNo
browser-agentNo – LLM-driven actions are one-way by designNoNo
Custom actor (e.g. Slack)Whatever you declareDeclare inverse: "..." to enableYes – pluck.undo(receipt) runs the inverse

When you call pluck.undo(signedReceipt) the runtime:

  1. Validates the receipt signature against the configured public key.
  2. Finds the actor that produced it (by match(source)).
  3. Looks up the action's inverse name on the ActionDef.
  4. Runs the inverse with the original input, signs the resulting receipt, and chains parentReceipt on it.

When the action is not reversible, undo throws ActionError with code ACTION_NOT_REVERSIBLE. Verification failure throws RECEIPT_VERIFICATION_FAILED. Missing actor throws NO_ACTOR. Branch on error.code – the strings are stable.

How this works without prior state

Undo reads everything it needs from the receipt itself – no separate state store, no database, no "what was the ID again?" lookup. Because the shipped HTTP-REST actions have no inverse today, the worked example uses the custom Slack actor from Custom actors below – it declares post-message with inverse delete-message:

TypeScript
// 1. Post a Slack message. The API answers with channel + ts (message id).
const posted = await pluck.act("slack://C123/chat", {
  action: "post-message",
  input: { text: "Buy milk" },
});

// The outer `.receipt` is a JS accessor for the whole receipt object; the inner
// actor body is `{ channel: "C123", ts: "1713..." }`, scrubbed of credentials.
const { channel, ts } = posted.signedReceipt!.receipt as { channel: string; ts: string };

// 2. Later – oh no. pluck.undo() reads channel + ts straight off the signed
//    receipt and fires the inverse `delete-message` against that exact id.
const rollback = await pluck.undo(posted.signedReceipt!);
console.log(rollback.confirmation);
// "Reverted post-message on slack://C123/chat via delete-message"

The receipt carries the request (URI, action, input) and the response (channel, ts) together, signed. That's all pluck.undo needs to compute the inverse – no out-of-band state store, no "which id did we just create?" bookkeeping; the audit trail and the rollback mechanism are the same artifact.

Shipped HTTP actions are not reversible today because REST semantics don't make POST→DELETE safe automatically (the new resource's ID location is server-defined, and PUT/PATCH need the prior body preserved somewhere outside the wire format). A custom actor that knows the resource shape – like the Slack one above – can provide the inverse in ~20 lines.


Confirmation strategies

Every act() call runs through a confirmation gate before the actor executes. Four strategies ship:

  • "auto" (default) – confirm only when the action is irreversible. Reversible actions go straight through.
  • "always" – confirm every action. Requires a callback; without one, reversible and irreversible actions block.
  • "never" – never confirm. Use only in tests or behind explicit opt-in.
  • "dry-run-only" – never execute. Preview the receipt and return without touching the network.
TypeScript
await act("https://api.example.com/users/42", {
  action: "delete",
  confirm: "always",
}, {
  onConfirm: async (preview) => {
    console.log(`About to delete ${preview.source}. Cost: $${preview.estimatedCost}`);
    return await prompt("Proceed? [y/N] ");
  },
});

The ActionPreview passed to your callback includes the source URI, the action name, the input, the reversibility flag, and an optional cost estimate. You make the call; return true to proceed.


Policy

.pluckpolicy.yaml sits next to your code. The evaluator runs against every action preview before the confirmation gate. When a rule fires, its deny or require block decides what happens next.

YAML
# .pluckpolicy.yaml
version: 1

rules:
  - name: "No destructive ops on production"
    match:
      urls: ["https://api.prod.example.com/**"]
      actions: ["delete", "mutate"]
    deny:
      all: true

  - name: "Large HTTP posts need explicit confirmation + dry-run"
    match:
      schemes: ["https"]
      actions: ["post"]
    require:
      confirm: "always"
      dryRun: true

  - name: "Signed receipts required for any Slack write"
    match:
      urls: ["https://slack.com/**"]
    require:
      signingKey: true

Load it at instance creation time and every call honors the rules:

TypeScript
const pluck = createPluck({ policy: "./.pluckpolicy.yaml" });
// or: createPluck({ policy: parsedPolicyObject })

When a deny block matches, the call throws ActionError("POLICY_DENIED"). When require.confirm is set, the confirmation strategy is forced for that call; when require.dryRun is true, the action is previewed only; when require.signingKey is true, the call fails unless the instance has a signing key configured.

Match fields supported today: urls (glob list), schemes (list), actions (list), actors (list). Enforcement fields: deny: { actions?, maxCost?, all? } and require: { confirm?, dryRun?, signingKey? }. The full list lives in PolicyRule in the core types.


Idempotency

Every action carries a stable idempotency key. When you omit one, the runtime derives it as hash(action + source + JSON.stringify(input)). When two calls share the same key, the second returns the cached signed receipt from the first, no network:

TypeScript
const result1 = await act("https://api.example.com/orders", {
  action: "post",
  input: { sku: "A1", quantity: 1 },
  idempotencyKey: "order-attempt-0193fa8f",
});

// Network retry storm – same key, same result. No duplicate order.
const result2 = await act("https://api.example.com/orders", {
  action: "post",
  input: { sku: "A1", quantity: 1 },
  idempotencyKey: "order-attempt-0193fa8f",
});

result1.signedReceipt === result2.signedReceipt; // true – cached, byte-identical

The default store is an in-memory map with FIFO eviction (default 1,000 entries; configurable via maxEntries and ttlMs). For durability across restarts, pass a SQLite store:

TypeScript
import { createPluck, createSqliteIdempotencyStore } from "@sizls/pluck";

const pluck = createPluck({
  idempotency: createSqliteIdempotencyStore({
    dbPath: "./.pluck/idempotency.sqlite",
  }),
});

Audit log

Every signed receipt is appended to an audit sink (pluggable via config). An AuditSink is any object with a write(entry) method. The built-in JSONL sink writes one receipt per line, append-only:

TypeScript
import { createPluck, createJsonlSink } from "@sizls/pluck";

const pluck = createPluck({
  auditSink: createJsonlSink("./audit/act.jsonl"),
});

JSONL is append-only but not tamper-evident on its own – pair with the DriftWatch Merkle-chained format for tamper-evident audit (see Recipe: DriftWatch Fleet).


Custom actors

Same pattern as connectors and extractors – defineActor is a typed pass-through:

TypeScript
import { createPluck, defineActor } from "@sizls/pluck";

const slack = defineActor({
  name: "slack",
  match: (uri) => uri.startsWith("slack://"),
  actions: () => [
    {
      name: "post-message",
      description: "Post a message to a Slack channel",
      reversible: true,
      inverse: "delete-message",
    },
    {
      name: "delete-message",
      description: "Delete a previously posted message",
      reversible: false,
    },
  ],
  async act(source, action, input, options) {
    if (action === "post-message") {
      const response = await fetch("https://slack.com/api/chat.postMessage", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${options.credentials?.token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(input),
        signal: options.signal,
      });
      const json = await response.json();
      return { receipt: { channel: json.channel, ts: json.ts } };
    }

    if (action === "delete-message") {
      // pluck.undo() hands us the original post-message receipt as input,
      // so { channel, ts } come straight off it – no external state store.
      const { channel, ts } = input as { channel: string; ts: string };
      const response = await fetch("https://slack.com/api/chat.delete", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${options.credentials?.token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ channel, ts }),
        signal: options.signal,
      });
      if (!response.ok) {
        throw new Error(`Slack delete failed: ${response.status}`);
      }
      return {
        receipt: { channel, ts, deleted: true },
        confirmation: `Deleted ts=${ts}`,
      };
    }

    throw new Error(`Unknown action: ${action}`);
  },
});

const pluck = createPluck({ actors: [slack] });
// pluck.actors.find("slack://..."); // "slack"

Full runnable example

A complete pluck.act() + verifyChain() round-trip – signs a receipt with Ed25519, verifies it offline with the public key alone. Opens in a fresh StackBlitz sandbox.


What's next

  • Sense – read signals below human perception; the final phase.
  • Recipe: DriftWatch Fleet – signed receipts across 40 SSH hosts, Merkle-chained audit log.
  • MCP-First Pipeline@sizls/pluck-mcp exposes every actor as an MCP tool with dry-run-by-default for AI agents.
Edit this page on GitHub
Previous
Shape
Next
Sense

Ready to build?

Install Pluck and follow the Quick Start guide to wire MCP-first data pipelines into your agents and fleets in minutes.

Get started →