crux-act
v0.1.2
Published
ACT — Actionable Communication Transport. A self-describing message standard: every message states its intent, priority, status, and explicit action steps. Framework-agnostic reference implementation for apps, servers, and AI agents.
Maintainers
Readme
crux-act
ACT — Actionable Communication Transport. A self-describing message standard, as a tiny, dependency-free TypeScript package.
npm i crux-act
pnpm add crux-act
yarn add crux-act
bun add crux-actThe problem
Email and chat move prose. Every message lands as free text, and the receiver has to read all of it and reverse-engineer the one question that actually matters:
What, if anything, do I need to do — and by when?
That reverse-engineering is the work nobody signed up for. A subject line is not a priority. "Can you…?" is not a task with an owner and a due date. An FYI and a hard blocker look identical in an inbox. Multiply that across Gmail, Slack, WhatsApp, Linear, Stripe webhooks… and your real to-do list is scattered, buried, and impossible to rank.
The idea
ACT fixes the layer underneath. An ACT message is self-describing — the sender states, in structured fields, exactly what they mean:
| field | what it answers |
|---|---|
| intent | is this a task, a request, a decision, or an update? |
| priority | low · medium · high |
| status | where is the work — new · actionable · in_progress · waiting · done? |
| action | is action required, and what are the explicit steps? |
Any app can emit ACT and any app can consume it. The receiver no longer guesses — they get a ranked, self-describing list of what needs them. The protocol is the product; the standard outlives any one client.
This is
crux-act— the protocol itself, as pure TypeScript: zero runtime dependencies, no React/UI, so any app, server, or AI agent can produce and consume ACT. Clients (a dashboard, an interactive demo) build on top of it.
Two ways messages come in
- Bridged — a generic channel (Gmail, Slack, WhatsApp) only has raw text, so
crux-act's classifier infers the ACT fields for you. - Native — a source that already speaks ACT (your app, a Stripe/Linear webhook, an AI agent) declares
intent/priority/action.stepsdirectly, and inference is skipped entirely. Strictly higher fidelity.
import { ingestEnvelope } from "crux-act"
// BRIDGED — raw text in, structured Work Object out (fields inferred)
const fromSlack = ingestEnvelope({
connector: "slack",
externalId: "slack-91",
sender: { name: "Priya", handle: "@priya" },
body: "Can you review my PR before standup? It's blocking the release. https://github.com/acme/app/pull/812",
receivedAt: new Date().toISOString(),
})
fromSlack.type // "request" ← inferred
fromSlack.priority // "high" ← inferred ("blocking")
fromSlack.title // "Can you review my PR before standup"
fromSlack.raw?.links // [{ url: "https://github.com/acme/app/pull/812", label: "github.com" }]
// NATIVE — declare the structure; nothing is inferred
const fromLinear = ingestEnvelope({
connector: "webhook",
externalId: "linear-ENG-512",
sender: { name: "Linear", handle: "[email protected]" },
subject: "You were assigned ENG-512",
body: "Decide whether ENG-512 ships this sprint and update the issue.",
receivedAt: new Date().toISOString(),
intent: "decision",
priority: "high",
action: {
required: true,
steps: ["Review the ENG-512 scope", "Decide: ship or defer", "Update the issue"],
},
})ingestEnvelope is the single inbound entry point — every channel flows through it identically. Sources are metadata; core logic never branches on which channel a message came from.
Carry ACT through any channel
A sender can embed ACT in a normal message body, so even a Gmail or WhatsApp message arrives natively structured — no platform change required:
import { embedAct, extractActBlock } from "crux-act"
const body = embedAct("Approve the Q3 plan before Monday.", {
intent: "decision",
priority: "high",
action: { required: true, steps: ["Open the plan", "Approve or request changes"] },
})
// body now ends with an invisible block: <!--act/1.0 {...}-->
extractActBlock(body) // → { intent: "decision", priority: "high", action: { ... } }ingestEnvelope handles this automatically: an embedded block is treated as native (no inference); a plain message is inferred; a malformed block falls back to inference. (Same idea as iCalendar riding inside an email.)
The message
The unit of the protocol is the ACT message:
interface ActMessage {
act: string // protocol version, e.g. "act/1.0"
id: string
intent: "task" | "request" | "decision" | "update"
summary: string // short, imperative, actionable title
body: string // human-readable detail
priority: "low" | "medium" | "high"
status: "new" | "actionable" | "in_progress" | "waiting" | "done"
action: { required: boolean; steps?: string[]; dueAt?: string }
sender: { name: string; handle: string }
recipient: { name: string; handle: string }
thread?: string
attachments?: { name: string; kind: AttachmentKind; size?: string; url?: string }[]
links?: { url: string; label?: string }[]
createdAt: string // ISO 8601
}action.steps is the heart of the standard. Instead of "let me know what you think when you get a chance," the receiver sees a checklist with a due date.
ACT hosts nothing.
attachment.urlpoints at wherever the producer already hosts the file (a Gmail/Slack link, an S3 URL…). The standard only references files — it never stores them. Same forlinks.
How to use
Project a Work Object to a canonical ACT message
import { toActMessage } from "crux-act"
const msg = toActMessage(work) // → ActMessage, exactly what an ACT-native app exchangesTriage: turn a flat list into "what needs me now"
import { bucketOf, itemsForView, countsByView, applyAndAdvance } from "crux-act"
bucketOf(work) // "today" | "waiting" | "later" | "done" (derived from status + priority)
itemsForView(items, "today") // WorkObject[]
countsByView(items) // { today, waiting, later, done }
// apply a change and advance selection to the next item still in the view
const { items: next, selectedId } = applyAndAdvance(items, "today", work.id, { status: "done" })Typed replies — a reply is itself an ACT message
You don't send free prose back; you send a typed response, and the reply's intent drives the thread's status.
import { REPLY_INTENTS, buildReplyMessage } from "crux-act"
// REPLY_INTENTS: answer → done · decision → done · acknowledge → waiting · delegate → waiting · decline → done
const reply = buildReplyMessage(
work,
"answer",
"Reviewed and merged — thanks.",
{ name: "Alex Mercer", handle: "[email protected]" }
)
reply.status // "done"Send an ACT message over HTTP (SDK)
sendEnvelope POSTs to an ACT ingest endpoint (POST /api/v1/ingest). This is the "implement an adapter and you're done" surface.
import { sendEnvelope } from "crux-act"
const res = await sendEnvelope(
{
body: "Approve the release",
intent: "decision",
priority: "high",
action: { required: true, steps: ["Review changelog", "Approve or hold"] },
},
"https://your-crux-host.example.com"
)
// → { ok: true, workId: "..." }…or straight from curl:
curl -X POST https://your-crux-host.example.com/api/v1/ingest \
-H 'content-type: application/json' \
-d '{ "body": "Build 2291 is green. Approve to ship.", "intent": "decision",
"priority": "high", "action": { "required": true, "steps": ["Review", "Approve"] } }'For AI agents — register one tool, emit structured work
ACT_TOOL is a ready-to-register tool/function definition. Its input_schema is Anthropic-shaped and doubles as an OpenAI function parameters — no glue code, no prose to format.
import { ACT_TOOL } from "crux-act"
// Anthropic
await anthropic.messages.create({
model: "claude-opus-4-8",
max_tokens: 1024,
tools: [ACT_TOOL], // { name: "send_act_message", description, input_schema }
messages,
})
// OpenAI
const openaiTool = {
type: "function",
function: { name: ACT_TOOL.name, description: ACT_TOOL.description, parameters: ACT_TOOL.input_schema },
}When the model calls send_act_message, POST the arguments to your ingest endpoint — the human sees a ranked, self-describing task instead of yet another message to decode.
Discover the protocol at runtime
import { ACT_DESCRIPTOR, ENVELOPE_SCHEMA } from "crux-act"
ACT_DESCRIPTOR // { protocol, version, enums, message (JSON Schema), tool, example, endpoints }
ENVELOPE_SCHEMA // JSON Schema for the inbound message a producer sendsAPI
| Area | Exports |
|---|---|
| Types | ActMessage, WorkObject, RawMessage, Intent, Priority, Status, WorkType, AttachmentKind, Attachment, LinkRef, Party, Source, ConnectorId, KnownConnectorId, KNOWN_CONNECTORS, ViewId, VIEWS, TYPE_LABEL, PRIORITY_LABEL |
| Protocol | ACT_VERSION, toActMessage, buildReplyMessage, REPLY_INTENTS, replyIntentMeta, ReplyIntent |
| Connector contract | InboundEnvelope, OutboundAction, OutboundResult, OutboundKind, OutboundTransport, dispatch, capabilityFor, ConnectorCapabilities, ConnectorMeta, CONNECTORS, getConnector, registerConnector, allConnectors |
| Ingest / classify | ingestEnvelope, IngestOptions, heuristicClassifier, Classifier, extractLinks |
| Validation | parseEnvelope, validateEnvelope, ParseResult, ParseOptions |
| Triage | bucketOf, itemsForView, countsByView, applyAndAdvance, ingest |
| Client SDK | sendEnvelope, EnvelopeInput, SendOptions, SendResult |
| Schema / agents | ENVELOPE_SCHEMA, ACT_TOOL, ACT_DESCRIPTOR, INTENTS, PRIORITIES, STATUSES, ATTACHMENT_KINDS |
Everything is exported from the root (crux-act); the triage helpers and the
schema are also available as the layered subpaths crux-act/board and
crux-act/schema if you want only those.
Ships as dual ESM + CommonJS with full type declarations and zero runtime dependencies. Works in Node, the browser, Bun, Deno, and edge runtimes.
Built for humans, apps, and AI agents
- Humans read a ranked board and act.
- Apps
POSTanInboundEnvelopeto/api/v1/ingest. - AI agents register
ACT_TOOLand call it.
Same protocol, three audiences, no second-class citizen.
Full specification
The complete spec — message schema, enums, inbound/outbound contracts, reply semantics, conformance levels, and worked examples — ships in this package as PROTOCOL.md (act/1.0).
License
MIT
