- Docs
- Actors Reference
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
idempotencyKeywithin a process. Swap in the SQLite store for multi-process dedupe. - Policy: Actions evaluate against a loaded
.pluckpolicy.yamlbefore execution. Deny rules short-circuit before any network / file-system touch. - Browser-agent response policy: The
browser-agentactor 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.
| # | Actor | match() claims | Actions exposed | reversible | Has inverse | pluck.undo() |
|---|---|---|---|---|---|---|
| 1 | graphql | /graphql path or graphql:// | mutate, query | query only | No | No |
| 2 | http-rest | http://, https:// | post, put, patch, delete | No | No | No |
| 3 | browser | http://, https:// (Playwright peer) | navigate, click-flow, fill-and-submit, extract-after-interact, upload, download | No | No | No – scripted interaction only |
| 4 | browser-agent | http://, https:// (LLM-driven) | agent-navigate | No | No | No – LLM-driven |
| 5 | shell-write | shell-write: | fs:write-file, fs:append, fs:make-dir, exec:command | No | No | No |
| 6 | email | mailto:, smtp://, smtps:// (nodemailer peer) | send, send-with-attachment | No | No | No |
| 7 | aws | aws://<service>/... (lazy @aws-sdk/client-* per action) | s3:put-object, s3:delete-object, dynamodb:put-item, sqs:send-message, sns:publish, lambda:invoke | s3:put-object | s3:delete-object | Yes – for s3:put-object |
| 8 | gcp | gcp://<service>/... (lazy @google-cloud/* per action) | storage:put-object, pubsub:publish, firestore:write-doc, functions:call | No | No | No |
| 9 | azure | azure://<service>/... (lazy @azure/* per action) | blob:put, servicebus:send, cosmos:put-item, functions:invoke | No | No | No |
| 10 | kafka | kafka://broker/topic (kafkajs peer; +aws-msk-iam-sasl-signer-js for MSK IAM) | publish, publish-batch | No | No | No |
| 11 | mqtt | mqtt://, mqtts:// (mqtt peer) | publish, publish-batch | No | No | No |
| 12 | imap | imap://, imaps:// (imapflow peer) | mark-read, mark-unread, flag, unflag, move, delete | mark-read/mark-unread/flag/unflag | Yes – flag pairs invert | Yes – for the flag pairs |
| 13 | grpc | grpc://, grpcs:// (@grpc/grpc-js + @grpc/proto-loader peers; +grpc-js-reflection-client when reflection is used) | call (or the URI's method name) | No | No | No |
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-s3fors3:*,@aws-sdk/client-sqsforsqs:send-message). A user who only callssqs:send-messagenever pays to install@aws-sdk/client-dynamodb. AMISSING_PEER_DEPerror 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 withUPLOAD_BODY_TOO_LARGErather than OOMing on buffer-everything. - Function URL validation.
gcp://functions/...andazure://functions/...URLs pass throughsafeHttpsUrl()– rejects userinfo (user:pass@hostwould exfiltrate bearer tokens), non-https, embedded schemes, and whitespace; enforces a host-suffix allowlist (.cloudfunctions.net/.run.appfor GCP;.azurewebsites.net/.azure-api.netfor 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 throughscrubConnectionString()before propagation –AccountKey=,SharedAccessKey=, andSharedAccessSignature=substrings are redacted from the error message. - Service mismatch code.
ACTOR_SERVICE_MISMATCHis raised when the action verb doesn't match the URI service prefix (e.g. callingdynamodb:put-itemonaws://s3/...).
shell-write + email security notes
shell-writereuses the shell connector's hardening: PATH pinning (noprocess.env.PATH), argv-only spawn (shell: false), env scrubbing, bounded output + timeout + SIGKILL escalation,AbortSignalwiring. File writes go through arealpath-of-parent confinement check againstallowedRootsand are opened withO_NOFOLLOWto defeat TOCTOU symlink escape. Refuses to defaultallowedRootswhenprocess.cwd()is/(container-root footgun).exec:commandruns an allowlisted subset of tools (seeDEFAULT_SHELL_WRITE_ALLOWLIST).emailrefuses SMTP URIs with inline passwords (SMTP_PASSWORD_IN_URI) – credentials must flow throughoptions.credentials. Receipt'ssourceURL 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.transportFactoryoption is test-only (throwsTRANSPORT_FACTORY_NOT_ALLOWEDin non-testNODE_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.
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:
{
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-rest – dryRun returns the preview without firing the request.
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).
| Action | What it does | Dry-run behavior |
|---|---|---|
navigate | Visit URL, optionally run InteractionStep[] after load, return final content | Runs normally (read-only) |
click-flow | Click a list of selectors in order | Runs normally |
fill-and-submit | Fill form fields, optionally click submit | Fills but skips the final submit click |
extract-after-interact | Run steps, then scrape page content (html / text / read) | Runs normally |
upload | setInputFiles(selector, paths), optionally submit | Validates paths but skips submit |
download | Click a trigger, capture download event, save to disk | Preview only – no navigation, no disk write |
// 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 unlessbaseDiris provided. - Absolute paths rejected unless
baseDiris 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.
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
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
| Code | When | How to resolve |
|---|---|---|
DOMAIN_NOT_ALLOWED | URL hostname not in allowedDomains | Add to policy.allowedDomains (IDN-normalized, no wildcards) |
PROTOCOL_NOT_ALLOWED | URL scheme not http: or https: | Refuse javascript:, data:, file:, about:, blob: URIs |
PORT_NOT_ALLOWED | URL port not in allowedPorts | Add to policy.allowedPorts |
POST_REDIRECT_OFF_ALLOWLIST | Server or client redirect landed off-allowlist | Narrow initial URL or widen allowlist with care |
ACTION_NOT_ALLOWED | Mutating action blocked by allowedActions | Add "mutate" to policy.allowedActions |
ACTION_BUDGET_EXCEEDED | Mutation total or per-domain cap hit | Raise policy.actionBudget.total / .perDomain |
ACTION_REJECTED_BY_HUMAN | onConfirm returned false | Provide a real callback or set humanInLoop: "none" |
HUMAN_CONFIRM_TIMEOUT | onConfirm didn't resolve in confirmTimeoutMs | Shorten timeout or fix the callback |
BROWSER_PATH_NOT_ALLOWED | Upload/download path rejected by validatePath | Use a relative path under input.baseDir |
AGENT_LLM_FAILED | LLM call errored | Check API key / model availability |
AGENT_MAX_STEPS | Loop exhausted without done | Raise maxSteps or narrow the instruction |
LLM_ABORTED | Caller aborted via AbortSignal | Catch and handle upstream |
Safe-defaults template
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():
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
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
- Concepts: Act – receipts, undo, policy, idempotency.
- Reference:
pluck verify-chain– walk the signed-receipt chain across connect / extract / shape / act. - IDEAS backlog – custom Zod-typed action inputs, policy bundles, federated receipt logs.