Skip to content

Reference

Actors Reference

All 13 built-in actors – http-rest, graphql, browser, browser-agent, shell-write, email, aws, gcp, azure, kafka (v0.21), mqtt (v0.32), imap (v0.32), grpc (v0.32) – their combined actions, and how each one integrates with the Act phase's signed-receipt + undo + policy layer.


How to read this page

The act phase routes an invocation to the actor whose match(uri) returns true AND whose actions() includes the requested action name. Dispatch is action-name-aware: a URL that matches both http-rest and browser is routed by the action you requested (post → http-rest, navigate → browser, agent-navigate → browser-agent). Unknown actions fail fast with NO_ACTOR_FOR_ACTION and a list of available actions for the URI.

  • Signed receipts: Every executed action produces a SignedReceipt (when a signing key is configured) with a canonical, verifiable body. See Concepts: Act → Signed receipts.
  • Idempotency: The default in-memory idempotency store dedupes invocations by idempotencyKey within a process. Swap in the SQLite store for multi-process dedupe.
  • Policy: Actions evaluate against a loaded .pluckpolicy.yaml before execution. Deny rules short-circuit before any network / file-system touch.
  • Browser-agent response policy: The browser-agent actor adds a second, LLM-specific gate – allowedDomains, allowedActions, actionBudget, humanInLoop. Secure-by-default, documented below.

See Concepts: Act for the full phase model.


The 13 built-in actors

Registry order is specific-first. Custom actors registered via createPluck({ actors: [...] }) prepend, except agent-navigate which is a reserved action name – custom actors declaring it are rejected at registration to protect the built-in policy gate from accidental replacement.

#Actormatch() claimsActions exposedreversibleHas inversepluck.undo()
1graphql/graphql path or graphql://mutate, queryquery onlyNoNo
2http-resthttp://, https://post, put, patch, deleteNoNoNo
3browserhttp://, https:// (Playwright peer)navigate, click-flow, fill-and-submit, extract-after-interact, upload, downloadNoNoNo – scripted interaction only
4browser-agenthttp://, https:// (LLM-driven)agent-navigateNoNoNo – LLM-driven
5shell-writeshell-write:fs:write-file, fs:append, fs:make-dir, exec:commandNoNoNo
6emailmailto:, smtp://, smtps:// (nodemailer peer)send, send-with-attachmentNoNoNo
7awsaws://<service>/... (lazy @aws-sdk/client-* per action)s3:put-object, s3:delete-object, dynamodb:put-item, sqs:send-message, sns:publish, lambda:invokes3:put-objects3:delete-objectYes – for s3:put-object
8gcpgcp://<service>/... (lazy @google-cloud/* per action)storage:put-object, pubsub:publish, firestore:write-doc, functions:callNoNoNo
9azureazure://<service>/... (lazy @azure/* per action)blob:put, servicebus:send, cosmos:put-item, functions:invokeNoNoNo
10kafkakafka://broker/topic (kafkajs peer; +aws-msk-iam-sasl-signer-js for MSK IAM)publish, publish-batchNoNoNo
11mqttmqtt://, mqtts:// (mqtt peer)publish, publish-batchNoNoNo
12imapimap://, imaps:// (imapflow peer)mark-read, mark-unread, flag, unflag, move, deletemark-read/mark-unread/flag/unflagYes – flag pairs invertYes – for the flag pairs
13grpcgrpc://, grpcs:// (@grpc/grpc-js + @grpc/proto-loader peers; +grpc-js-reflection-client when reflection is used)call (or the URI's method name)NoNoNo

Custom actors are the path to first-class undo – declare inverse: "..." on an action and pluck.undo(signedReceipt) fires the inverse automatically. See the Slack example in Concepts: Act → Undo.

Read-write parity (v0.32). Every IMAP / MQTT / gRPC / Kafka URI you can pluck() you can also pluck.act() – the connectors handle reads, the actors handle writes. URIs are symmetrical: pluck("mqtt://broker/topic") subscribes; pluck.act("mqtt://broker/topic", { action: "publish", input: { value: "..." } }) publishes.

Cloud actor security notes

All three cloud actors share the same hardening posture:

  • Lazy peer loading. Each action dynamically imports only the SDK it needs (e.g. aws@aws-sdk/client-s3 for s3:*, @aws-sdk/client-sqs for sqs:send-message). A user who only calls sqs:send-message never pays to install @aws-sdk/client-dynamodb. A MISSING_PEER_DEP error with an install hint fires on the first call if the relevant SDK isn't present.
  • Upload caps. Every cloud put / publish / invoke goes through assertUploadSize() (100 MB default). Agents passing a 10 GB Buffer fail closed with UPLOAD_BODY_TOO_LARGE rather than OOMing on buffer-everything.
  • Function URL validation. gcp://functions/... and azure://functions/... URLs pass through safeHttpsUrl() – rejects userinfo (user:pass@host would exfiltrate bearer tokens), non-https, embedded schemes, and whitespace; enforces a host-suffix allowlist (.cloudfunctions.net / .run.app for GCP; .azurewebsites.net / .azure-api.net for Azure).
  • Response-body exclusion. Function receipts do NOT include a preview of the response body. A misbehaving / attacker-controlled reflector function could otherwise echo the Authorization: Bearer <token> header into a signed audit record.
  • Azure connection-string scrubbing. SDK errors from @azure/* pass through scrubConnectionString() before propagation – AccountKey=, SharedAccessKey=, and SharedAccessSignature= substrings are redacted from the error message.
  • Service mismatch code. ACTOR_SERVICE_MISMATCH is raised when the action verb doesn't match the URI service prefix (e.g. calling dynamodb:put-item on aws://s3/...).

shell-write + email security notes

  • shell-write reuses the shell connector's hardening: PATH pinning (no process.env.PATH), argv-only spawn (shell: false), env scrubbing, bounded output + timeout + SIGKILL escalation, AbortSignal wiring. File writes go through a realpath-of-parent confinement check against allowedRoots and are opened with O_NOFOLLOW to defeat TOCTOU symlink escape. Refuses to default allowedRoots when process.cwd() is / (container-root footgun). exec:command runs an allowlisted subset of tools (see DEFAULT_SHELL_WRITE_ALLOWLIST).
  • email refuses SMTP URIs with inline passwords (SMTP_PASSWORD_IN_URI) – credentials must flow through options.credentials. Receipt's source URL has userinfo stripped. Subject is SHA-256-hashed (first 16 hex chars) + length, never stored verbatim. Attachments capped at 25 MB each / 50 MB total. transportFactory option is test-only (throws TRANSPORT_FACTORY_NOT_ALLOWED in non-test NODE_ENV).

HTTP REST actor

Wraps standard REST verbs as ActionDefs. Honours Idempotency-Key headers, supports dryRun: true (returns the preview body + headers without firing the request), and emits a signed receipt whose receipt body contains the HTTP status, response body (credential-scrubbed), and timing.

TypeScript
await pluck.act("https://api.example.com/todos", {
  action: "post",
  input: { title: "Buy milk" },
  dryRun: false,
  idempotencyKey: "todo-2026-04-20-01",
});

Dry-run output:

TypeScript
{
  kind: "act",
  text: "[dry-run] POST https://api.example.com/todos",
  receipt: {
    action: "post",
    preview: { method: "POST", url: "…", body: { title: "Buy milk" } },
    dryRun: true,
  },
}

Live output includes the response body, status, and – when a signing key is configured – a SignedReceipt that can be verified with pluck verify-chain against the corresponding public key.

Actions + inverses

http-rest declares four actions. None have an inverse – a DELETE can't be reversed without knowing the prior state, and a POST's idempotent inverse is a DELETE on the created resource, which requires the caller to track the returned ID. See IDEAS.md → Act for the "declarative inverses via manifest" proposal.


GraphQL actor

Sends a mutation string to a GraphQL endpoint. Parses the mutation via a zero-dep tokenizer to pull the operation name for receipt + audit purposes. Supports variables, auth via Authorization header, and – like http-restdryRun returns the preview without firing the request.

TypeScript
await pluck.act("https://api.example.com/graphql", {
  action: "mutate",
  input: {
    mutation: `mutation CreatePost($title: String!) {
      createPost(title: $title) { id }
    }`,
    variables: { title: "Hello" },
  },
  dryRun: true,
});

Receipt body contains the parsed operation name + variables (credential-scrubbed), the response data + errors, and the round-trip timing.


Browser actor

Scripted Playwright-backed interaction. Six actions cover the common browser-automation surface; each action's input is shape-specific, each receipt captures the URL sequence and optional pre/post screenshots.

Requires the playwright optional peer (pnpm add playwright).

ActionWhat it doesDry-run behavior
navigateVisit URL, optionally run InteractionStep[] after load, return final contentRuns normally (read-only)
click-flowClick a list of selectors in orderRuns normally
fill-and-submitFill form fields, optionally click submitFills but skips the final submit click
extract-after-interactRun steps, then scrape page content (html / text / read)Runs normally
uploadsetInputFiles(selector, paths), optionally submitValidates paths but skips submit
downloadClick a trigger, capture download event, save to diskPreview only – no navigation, no disk write
TypeScript
// Scripted multi-step: search Hacker News for a term, grab the first result.
await pluck.act("https://news.ycombinator.com", {
  action: "extract-after-interact",
  input: {
    steps: [
      { action: "fill", selector: "input[name=q]", value: "ed25519" },
      { action: "click", selector: "button[type=submit]" },
      { action: "waitForNavigation" },
    ],
    mode: "read",
  },
});

Path validation

upload and download validate paths against a simple rule set:

  • .. segments rejected (catches both ../ and double-encoded %252e%252e).
  • ~ home-expansion rejected.
  • Windows drive-letter prefixes (C:foo) rejected unless baseDir is provided.
  • Absolute paths rejected unless baseDir is provided AND the resolved path stays inside it.

Validation errors throw ActionError("...", "BROWSER_PATH_NOT_ALLOWED").


Browser-agent actor (LLM-driven with response policy)

One action: agent-navigate. Takes input.instruction (natural-language goal) + optional input.schema (Zod schema for structured extraction) + input.policy (see below) + input.maxSteps (default 10).

The agent drives the browser via a loop: the LLM proposes an action, the response-policy gate checks it, if approved the action runs, page content is wrapped with an untrusted-content delimiter and fed back to the LLM. Terminates on done, give_up, maxSteps, or any policy halt.

TypeScript
await pluck.act("https://news.ycombinator.com", {
  action: "agent-navigate",
  input: {
    instruction: "Find the top story and its comment count",
    policy: {
      allowedDomains: ["news.ycombinator.com"],
      allowedActions: ["read"],
      actionBudget: { total: 3 },
    },
  },
});

The AgentPolicy shape

TypeScript
interface AgentPolicy {
  /** Hard domain allowlist. Default: the source URI's hostname (same-origin). */
  allowedDomains?: string[];
  /** Allowed ports. Default: [80, 443]. */
  allowedPorts?: number[];
  /** Allowed action classes. Default: ["read"] (mutations opt-in). */
  allowedActions?: Array<"read" | "mutate">;
  /** Mutation caps. Default: { total: 5, perDomain: 3 }. */
  actionBudget?: { total?: number; perDomain?: number };
  /** When to require human approval. Default: "mutations". */
  humanInLoop?: "mutations" | "all" | "none";
  /** Callback that runs before each gated action. Default: always-false (rejects). */
  onConfirm?: (preview: AgentActionPreview) => Promise<boolean> | boolean;
  /** Hung-callback timeout in ms. Default: 30000. */
  confirmTimeoutMs?: number;
  /** Post-action settle delay for client-side redirects. Default: 200. */
  settleMs?: number;
}

All four layers (domain / action-class / budget / human) are evaluated in order. Any failure halts the run with a typed error code.

Halt codes

CodeWhenHow to resolve
DOMAIN_NOT_ALLOWEDURL hostname not in allowedDomainsAdd to policy.allowedDomains (IDN-normalized, no wildcards)
PROTOCOL_NOT_ALLOWEDURL scheme not http: or https:Refuse javascript:, data:, file:, about:, blob: URIs
PORT_NOT_ALLOWEDURL port not in allowedPortsAdd to policy.allowedPorts
POST_REDIRECT_OFF_ALLOWLISTServer or client redirect landed off-allowlistNarrow initial URL or widen allowlist with care
ACTION_NOT_ALLOWEDMutating action blocked by allowedActionsAdd "mutate" to policy.allowedActions
ACTION_BUDGET_EXCEEDEDMutation total or per-domain cap hitRaise policy.actionBudget.total / .perDomain
ACTION_REJECTED_BY_HUMANonConfirm returned falseProvide a real callback or set humanInLoop: "none"
HUMAN_CONFIRM_TIMEOUTonConfirm didn't resolve in confirmTimeoutMsShorten timeout or fix the callback
BROWSER_PATH_NOT_ALLOWEDUpload/download path rejected by validatePathUse a relative path under input.baseDir
AGENT_LLM_FAILEDLLM call erroredCheck API key / model availability
AGENT_MAX_STEPSLoop exhausted without doneRaise maxSteps or narrow the instruction
LLM_ABORTEDCaller aborted via AbortSignalCatch and handle upstream

Safe-defaults template

TypeScript
const safe: AgentPolicy = {
  allowedDomains: ["your-domain.com"],
  allowedPorts: [443],
  allowedActions: ["read"],
  actionBudget: { total: 3, perDomain: 3 },
  humanInLoop: "mutations",
  confirmTimeoutMs: 10_000,
  onConfirm: async (preview) => {
    return await prompt(`Allow ${preview.action} on ${preview.url}?`);
  },
};

See Security for the full threat model, delimiter-wrapping story, and known limitations.

Reserved action name

agent-navigate is reserved. Custom actors that declare it are rejected at registration with a clear error message – this prevents a custom actor from silently shadowing the built-in policy gate. Every other browser action name (navigate, click-flow, etc.) is free to override.


Custom actors

defineActor() is the typed authoring helper. Same shape as defineConnector() / defineExtractor():

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

const slack = defineActor({
  name: "slack",
  match: (uri) => uri.startsWith("slack://"),
  actions: {
    "post-message": {
      input: { channel: "string", text: "string" },
      inverse: "delete-message",
    },
    "delete-message": {
      input: { channel: "string", ts: "string" },
    },
  },
  async act(req, ctx) {
    // req.action → "post-message" | "delete-message"
    // req.input → typed input
    // ctx.signingKey, ctx.idempotency – available from the PluckInstance
    // Returns an ActResult
  },
});

const pluck = createPluck({ actors: [slack] });

Custom actors are prepended to the registry so they win over built-ins that match the same URI.


Policy gates

Every actor invocation passes through evaluatePolicy() against the currently-loaded PluckPolicy. Deny rules fail fast with ActionError("POLICY_DENIED"); allow rules continue; confirm rules surface the preview to the configured ConfirmCallback before execution.

See Concepts: Act → Policy for the rule DSL and docs/IDEAS.md for the "Policy bundles as npm packages" proposal (@sizls/policy-sox-2026, @sizls/policy-gdpr-dsar, @sizls/policy-hipaa).


Introspection

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

const pluck = createPluck();

pluck.actors.list();
// ["graphql", "http-rest", "browser", "browser-agent"]

pluck.actors.find("https://api.example.com/todos");
// "http-rest" – first URI match; action-name is checked on dispatch.
pluck.actors.findForAction("https://api.example.com/todos", "navigate");
// "browser"

pluck.actors.find("slack://channel/ops");
// undefined – unless a custom actor is registered

What's next

Edit this page on GitHub
Previous
Extractors

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 →