A standalone TypeScript/Node.js service that bridges autonomous AI agents and human decision-makers. Agents call HCP to surface decision points (approvals, clarifications, escalations), humans respond via a web portal or Slack, and the service maintains a full audit trail.
Built for NanoClaw — a WhatsApp-based personal Claude assistant — but usable by any agent system that can make HTTP calls.
- Overview
- Architecture
- Quick Start
- Configuration
- API Reference
- State Machine
- Timeout & Fallback Behaviors
- TypeScript SDK
- Web Portal
- Slack Integration
- NanoClaw Integration
- Authentication
- Database Schema
- Type Reference
- Testing
- Project Structure
- Development
Agents currently have no way to request human approval, clarification, or escalation — they either proceed autonomously or fail silently. HCP fills this gap:
- An agent submits a Coordination Request (CR) with context, urgency, and a timeout policy.
- HCP routes the CR to the designated human responder via the web portal or Slack.
- The human reviews and responds (approve, reject, provide input, etc.).
- The agent polls or receives the structured response and continues.
- Every state change is recorded in an append-only audit log.
| Concern | Choice |
|---|---|
| Runtime | Node.js (ES2022) |
| Language | TypeScript (strict mode) |
| HTTP framework | Fastify 5 |
| Database | SQLite via better-sqlite3 |
| Validation | zod |
| IDs | ULID |
| Build | tsup |
| Test | vitest |
| Slack | @slack/web-api |
| Portal | Vanilla HTML + Alpine.js |
+------------------+
| Web Portal |
| (Alpine.js) |
+--------+---------+
|
+----------------+ REST API +---------+----------+ Slack API
| AI Agent | -------------> | HCP Server | ------------> Slack
| (NanoClaw etc) | <------------- | (Fastify + SQLite) | <----------- (interactions)
+----------------+ Poll/SSE +---------+----------+
|
+--------+---------+
| Audit Store |
| (append-only) |
+------------------+
Key components:
- Gateway API — REST endpoints for CR lifecycle management
- State Machine — Enforces valid state transitions with optimistic concurrency
- Manual Router — Routes CRs to the agent-specified responder
- Timeout Scheduler — Polls every 10s for expired CRs, executes fallback behaviors
- Audit Store — Append-only event log for every state change
- SSE — Real-time event stream for connected clients
- Slack Adaptor — Block Kit notifications with interactive buttons
- Web Portal — Lightweight SPA for human responders
- Node.js 22+
- npm 9+
# Install dependencies
npm install
# Create an API key for your agent
npm run setup-key -- my-agent "My Agent Key"
# Output:
# API key created for agent "my-agent":
# Key ID: 01ABC...
# API Key: hcp_a1b2c3...
# Store this key securely — it cannot be retrieved again.
# Start the server (development mode with hot reload)
npm run dev
# Or build and run in production
npm run build
npm startThe server starts on http://localhost:3100 by default.
# Health check
curl http://localhost:3100/health
# Submit a coordination request
curl -X POST http://localhost:3100/v1/requests \
-H "Authorization: Bearer hcp_a1b2c3..." \
-H "Content-Type: application/json" \
-d '{
"intent": "APPROVAL",
"urgency": "HIGH",
"context_package": {
"summary": "Deploy v2.1.0 to production",
"detail": "Includes new auth flow and 3 bug fixes."
},
"timeout_policy": {
"timeout_seconds": 600,
"fallback": "BLOCK"
},
"routing_hints": {
"responder_id": "ops-lead",
"channel": "portal"
}
}'
# Open the portal to respond
open "http://localhost:3100/portal/?responder_id=ops-lead"
# After responding, poll the result
curl http://localhost:3100/v1/requests/<request_id> \
-H "Authorization: Bearer hcp_a1b2c3..."Set via environment variables or a .env file. See .env.example for defaults.
| Variable | Default | Description |
|---|---|---|
HCP_PORT |
3100 |
HTTP server port |
HCP_DB_PATH |
~/.hcp/hcp.db |
SQLite database file path |
HCP_BASE_URL |
http://localhost:3100 |
Public base URL (used in Slack messages and portal links) |
SLACK_BOT_TOKEN |
(empty) | Slack Bot User OAuth Token (xoxb-...). Leave empty to disable Slack. |
The database directory is created automatically if it doesn't exist.
All /v1/* endpoints require authentication via Authorization: Bearer <api_key> header. Public endpoints (/health, /v1/events, /slack/interactions, /portal/*) do not.
Submit a new CR. If an idempotency_key is provided and a matching CR exists, the existing CR is returned (200) instead of creating a duplicate (201).
Request body: See CreateCRSchema.
Response: 201 Created with the full CoordinationRequest object.
curl -X POST http://localhost:3100/v1/requests \
-H "Authorization: Bearer $HCP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"intent": "APPROVAL",
"urgency": "MEDIUM",
"context_package": { "summary": "Delete 500 inactive user accounts" },
"timeout_policy": { "timeout_seconds": 3600, "fallback": "AUTO_REJECT" },
"routing_hints": { "responder_id": "admin", "channel": "portal" }
}'Fetch a single CR by ID. If the CR is in RESPONDED state, it automatically transitions to DELIVERED (so agents don't need a separate acknowledge call).
Response: 200 OK with the full CoordinationRequest object.
List CRs with optional filters.
| Query Parameter | Type | Description |
|---|---|---|
agent_id |
string | Filter by agent |
state |
string | Filter by state (e.g., PENDING_RESPONSE) |
intent |
string | Filter by intent type |
urgency |
string | Filter by urgency level |
responder_id |
string | Filter by assigned responder |
limit |
number | Max results (default: 50) |
offset |
number | Pagination offset (default: 0) |
Response: 200 OK with { requests: CoordinationRequest[] }.
Cancel a CR. Only works from cancellable states: SUBMITTED, ROUTING, PENDING_RESPONSE, ESCALATED.
Response: 200 OK with { status: "cancelled", request_id: "..." }.
Returns 409 Conflict if the CR is in a non-cancellable state.
Submit a human response to a CR. Only works when the CR is in PENDING_RESPONSE state.
Request body:
{
"response_data": { "decision": "approved", "comment": "LGTM" },
"responded_by": "ops-lead"
}Response: 200 OK with { status: "responded", request_id: "..." }.
Query the append-only audit log.
| Query Parameter | Type | Description |
|---|---|---|
request_id |
string | Filter by CR |
event_type |
string | Filter by event type |
limit |
number | Max results (default: 100) |
offset |
number | Pagination offset (default: 0) |
Response: 200 OK with { events: AuditEvent[] }.
Real-time event stream. Connect with EventSource or curl.
| Query Parameter | Type | Description |
|---|---|---|
agent_id |
string | Filter events by agent |
responder_id |
string | Filter events by responder |
Events emitted:
connected— sent on initial connection with{ client_id }.state_change— sent when any CR changes state, with{ request_id, state, agent_id }.
curl -N http://localhost:3100/v1/events?responder_id=ops-leadResponse:
{
"status": "ok",
"timestamp": "2025-01-15T12:00:00.000Z",
"sse_clients": 2
}Every CR progresses through a defined set of states. Invalid transitions are rejected.
+-----------+
| SUBMITTED |
+-----+-----+
|
+-----v-----+
+--->| ROUTING |<---+
| +-----+-----+ |
| | |
| +-----v--------+ |
| | PENDING_ | |
| | RESPONSE | |
| +--+---+---+---+ |
| | | | |
+----+--+ | | +-----+----+
|ESCALATED|<-+ | |TIMED_OUT |
+---------+ | +----------+
|
+------v----+
| RESPONDED |
+------+----+
|
+------v----+
| DELIVERED |
+-----------+
Transitions:
| From | To | Trigger |
|---|---|---|
| SUBMITTED | ROUTING | Manual router picks up CR |
| ROUTING | PENDING_RESPONSE | Router delivers to responder |
| PENDING_RESPONSE | RESPONDED | Human submits response |
| RESPONDED | DELIVERED | Agent polls the CR (auto-transition) |
| PENDING_RESPONSE | TIMED_OUT | Timeout scheduler fires |
| PENDING_RESPONSE | ESCALATED | Timeout with ESCALATE fallback |
| ESCALATED | ROUTING | Re-routed to escalation responder |
| Any active state | CANCELLED | Agent cancels the CR |
Terminal states: DELIVERED, TIMED_OUT, CANCELLED
Cancellable states: SUBMITTED, ROUTING, PENDING_RESPONSE, ESCALATED
Each CR includes a timeout_policy specifying what happens if no human responds before the deadline. The timeout scheduler polls every 10 seconds.
| Fallback | Behavior | Resulting State |
|---|---|---|
AUTO_APPROVE |
Injects { decision: "approved", auto: true } as the response |
RESPONDED |
AUTO_REJECT |
Injects { decision: "rejected", auto: true } as the response |
RESPONDED |
ESCALATE |
Re-routes to escalation_responder_id with a fresh timeout |
PENDING_RESPONSE (via ESCALATED -> ROUTING) |
BLOCK |
Marks the CR as timed out; agent must handle the failure | TIMED_OUT |
FAIL |
Same as BLOCK — CR times out | TIMED_OUT |
SKIP |
Same as BLOCK — CR times out | TIMED_OUT |
If ESCALATE is specified but no escalation_responder_id is provided, the CR falls through to TIMED_OUT.
The SDK provides a typed client for agent-side integration. Import from the hcp/sdk export.
import { HCPClient } from "hcp/sdk";
const hcp = new HCPClient({
baseUrl: "http://localhost:3100",
apiKey: "hcp_a1b2c3...",
});Creates a CR and returns immediately. The CR starts in SUBMITTED state.
const cr = await hcp.submit({
intent: "APPROVAL",
urgency: "HIGH",
context_package: {
summary: "Deploy v2.1.0 to production",
detail: "Includes new auth flow and 3 bug fixes.",
},
timeout_policy: {
timeout_seconds: 600,
fallback: "AUTO_REJECT",
},
routing_hints: {
responder_id: "ops-lead",
channel: "portal",
},
});
console.log(cr.request_id); // "01ABC..."Submits a CR and polls until it reaches a terminal state (DELIVERED, TIMED_OUT, or CANCELLED). This is the primary method for agent use.
const result = await hcp.coordinate(
{
intent: "DECISION",
urgency: "MEDIUM",
context_package: {
summary: "Which database should we migrate to?",
metadata: { options: ["PostgreSQL", "MySQL", "MongoDB"] },
},
timeout_policy: { timeout_seconds: 3600, fallback: "FAIL" },
routing_hints: { responder_id: "tech-lead", channel: "slack", slack_channel_id: "C0123456789" },
},
{ pollIntervalMs: 3000 }
);
if (result.state === "DELIVERED") {
console.log("Decision:", result.response_data);
} else {
console.log("Timed out or cancelled:", result.state);
}| Option | Default | Description |
|---|---|---|
pollIntervalMs |
2000 |
Milliseconds between polls |
maxWaitMs |
timeout_seconds * 1000 + 5000 |
Max time to wait before throwing |
const cr = await hcp.getRequest("01ABC...");
// If state is RESPONDED, auto-transitions to DELIVEREDawait hcp.respond("01ABC...", {
response_data: { decision: "approved" },
responded_by: "ops-lead",
});await hcp.cancelRequest("01ABC...");const { requests } = await hcp.listRequests({
state: "PENDING_RESPONSE",
urgency: "CRITICAL",
});const { events } = await hcp.queryAudit({
request_id: "01ABC...",
});The SDK re-exports all types and zod schemas for convenience:
import {
Intent, Urgency, State, Fallback,
CreateCRSchema, SubmitResponseSchema,
type CoordinationRequest, type AuditEvent,
type CreateCRInput, type SubmitResponseInput,
} from "hcp/sdk";A lightweight Alpine.js SPA served at /portal/. Human responders use it to view and respond to pending CRs.
http://localhost:3100/portal/?responder_id=<your-id>
Optionally focus on a single CR:
http://localhost:3100/portal/?responder_id=ops-lead&request_id=01ABC...
- Real-time updates via SSE — new CRs appear automatically
- Urgency indicators — color-coded left borders and badges (red/orange/blue/grey)
- Approve/Reject buttons for APPROVAL intent CRs
- Free-text input for all other intent types (Cmd+Enter to submit)
- Context display — summary, detail, metadata, and timestamps
- Connection status — shows Connected/Disconnected badge with auto-reconnect
The portal does not require authentication — it's designed for internal network use. The responder_id query parameter controls which CRs are displayed.
HCP can send Block Kit notifications to Slack and handle interactive button clicks.
- Create a Slack app at api.slack.com/apps
- Add Bot Token Scopes:
chat:write - Enable Interactivity and set the Request URL to
https://<your-hcp-url>/slack/interactions - Install the app to your workspace
- Set
SLACK_BOT_TOKEN=xoxb-...in your environment
Set channel: "slack" and provide slack_channel_id in routing hints:
{
"routing_hints": {
"responder_id": "ops-lead",
"channel": "slack",
"slack_channel_id": "C0123456789"
}
}Messages include:
- Header with intent type
- Urgency indicator with emoji (CRITICAL=rotating_light, HIGH=warning, MEDIUM=large_blue_circle, LOW=white_circle)
- Agent ID and CR ID
- Summary and detail from the context package
- Approve/Reject buttons (for APPROVAL intent only)
- Portal link for full context and non-approval responses
When a user clicks Approve or Reject in Slack:
- Slack sends a POST to
/slack/interactions - HCP transitions the CR from
PENDING_RESPONSEtoRESPONDED - The response is attributed to
slack:<username> - Both
SLACK_INTERACTIONandCR_RESPONDEDaudit events are recorded
HCP integrates with NanoClaw via its existing file-based IPC system. See src/nanoclaw/integration.ts for copy-paste code.
- Add
HCP_BASE_URLandHCP_API_KEYto NanoClaw's environment - Add an
hcp_coordinatecase toprocessTaskIpc() - Register the
hcp_coordinateMCP tool inipc-mcp-stdio.ts
// In NanoClaw's processTaskIpc():
case 'hcp_coordinate': {
const { HCPClient } = await import('hcp/sdk');
const client = new HCPClient({
baseUrl: process.env.HCP_BASE_URL!,
apiKey: process.env.HCP_API_KEY!,
});
const result = await client.coordinate(task.params);
return { success: true, data: result };
}Once registered, agents can call the tool naturally:
"I need approval to proceed with the database migration. Let me check with the ops team."
Agent calls
hcp_coordinatewith intent=APPROVAL, summary="Database migration to PostgreSQL", routing to ops-lead
API keys are SHA256-hashed and stored in the database. The raw key is only shown once at creation time.
npm run setup-key -- <agent-id> "<label>"
# Examples:
npm run setup-key -- nanoclaw "NanoClaw Production"
npm run setup-key -- test-agent "Development Testing"Include the raw key in the Authorization header:
Authorization: Bearer hcp_a1b2c3d4e5f6...
The agent_id associated with the key is automatically attached to all CRs created with that key.
Keys can be revoked by setting revoked_at in the database:
UPDATE api_keys SET revoked_at = datetime('now') WHERE agent_id = 'compromised-agent';SQLite with WAL mode, foreign keys enabled, and 5s busy timeout. The database file is created at ~/.hcp/hcp.db by default.
| Column | Type | Description |
|---|---|---|
request_id |
TEXT PK | ULID |
agent_id |
TEXT | Agent that submitted the CR |
intent |
TEXT | APPROVAL, CLARIFICATION, etc. |
urgency |
TEXT | CRITICAL, HIGH, MEDIUM, LOW |
state |
TEXT | Current state |
context_package |
TEXT (JSON) | Summary, detail, metadata, attachments |
response_schema |
TEXT (JSON) | Expected response format |
timeout_policy |
TEXT (JSON) | Timeout seconds + fallback behavior |
routing_hints |
TEXT (JSON) | Responder ID, channel, Slack channel |
trace_id |
TEXT | Optional trace correlation ID |
idempotency_key |
TEXT UNIQUE | Prevents duplicate submissions |
responder_id |
TEXT | Assigned human responder |
response_data |
TEXT (JSON) | Human's response |
responded_by |
TEXT | Who responded |
responded_at |
TEXT | When they responded (ISO 8601) |
submitted_at |
TEXT | Creation timestamp |
updated_at |
TEXT | Last state change |
timeout_at |
TEXT | Deadline for response |
delivered_at |
TEXT | When agent received the response |
Indexes: state, agent_id, responder_id, timeout_at
| Column | Type | Description |
|---|---|---|
event_id |
TEXT PK | ULID |
request_id |
TEXT FK | Associated CR |
event_type |
TEXT | CR_SUBMITTED, CR_RESPONDED, SLACK_NOTIFIED, etc. |
actor |
TEXT | Who triggered the event |
actor_type |
TEXT | AGENT, HUMAN, or SYSTEM |
payload |
TEXT (JSON) | Event-specific data |
created_at |
TEXT | ISO 8601 timestamp |
| Column | Type | Description |
|---|---|---|
key_id |
TEXT PK | ULID |
key_hash |
TEXT UNIQUE | SHA256 hash of the raw API key |
agent_id |
TEXT | Agent this key authenticates as |
label |
TEXT | Human-readable label |
scopes |
TEXT (JSON) | Reserved for future use |
created_at |
TEXT | Creation timestamp |
revoked_at |
TEXT | Revocation timestamp (null if active) |
type Intent = "APPROVAL" | "CLARIFICATION" | "ESCALATION" | "NOTIFICATION" | "DECISION" | "REVIEW" | "INPUT";
type Urgency = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW";
type State = "SUBMITTED" | "ROUTING" | "PENDING_RESPONSE" | "RESPONDED" | "DELIVERED" | "ESCALATED" | "TIMED_OUT" | "CANCELLED";
type Fallback = "AUTO_APPROVE" | "AUTO_REJECT" | "ESCALATE" | "BLOCK" | "FAIL" | "SKIP";
type ActorType = "AGENT" | "HUMAN" | "SYSTEM";
type AuditEventType = "CR_SUBMITTED" | "CR_ROUTING" | "CR_PENDING_RESPONSE" | "CR_RESPONDED" | "CR_DELIVERED" | "CR_ESCALATED" | "CR_TIMED_OUT" | "CR_CANCELLED" | "SLACK_NOTIFIED" | "SLACK_INTERACTION";{
intent: Intent; // Required
urgency: Urgency; // Required
context_package: { // Required
summary: string; // Required (min 1 char)
detail?: string;
metadata?: Record<string, unknown>;
attachments?: Array<{
type: string;
name: string;
content: string;
}>;
};
response_schema?: { // Optional
type: "choice" | "text" | "structured";
options?: Array<{ key: string; label: string; description?: string }>;
json_schema?: Record<string, unknown>;
};
timeout_policy: { // Required
timeout_seconds: number; // Positive integer
fallback: Fallback;
escalation_responder_id?: string; // Required if fallback is ESCALATE
};
routing_hints: { // Required
responder_id: string; // Required (min 1 char)
channel?: "portal" | "slack"; // Default: "portal"
slack_channel_id?: string; // Required if channel is "slack"
};
trace_id?: string;
idempotency_key?: string;
}{
response_data: Record<string, unknown>; // Required — the actual response
responded_by: string; // Required — who responded (min 1 char)
}Tests use vitest with in-memory SQLite databases for isolation.
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run a specific test file
npx vitest run test/unit/state-machine.test.ts| File | Tests | Coverage |
|---|---|---|
test/unit/state-machine.test.ts |
8 | Valid/invalid transitions, concurrent state changes, additional updates |
test/unit/audit-store.test.ts |
4 | Append events, filter by request_id/event_type, pagination |
test/integration/api.test.ts |
11 | All endpoints, auth, validation, idempotency, 404s |
test/integration/e2e-flow.test.ts |
2 | Full CR lifecycle (submit -> respond -> deliver), cancellation |
| Total | 25 |
HCP/
package.json
tsconfig.json
tsup.config.ts
vitest.config.ts
.env.example
src/
index.ts # Entry point: server + timeout scheduler
config.ts # Env var loading with defaults
db/
connection.ts # SQLite singleton (~/.hcp/hcp.db)
schema.ts # Table definitions + versioning
types/
common.ts # Intent, Urgency, State, Fallback enums
cr.ts # CR interfaces + zod schemas
audit.ts # Audit event types
engine/
state-machine.ts # State transitions with validation
timeout-scheduler.ts # Polls expired CRs, executes fallbacks
manual-router.ts # Routes to agent-specified responder
api/
server.ts # Fastify setup + route registration
middleware/
auth.ts # Bearer token -> SHA256 lookup
routes/
requests.ts # CR CRUD + respond endpoint
audit.ts # Audit log queries
sse.ts # Server-sent events stream
health.ts # Health check
audit/
store.ts # Append-only audit writes + queries
adaptors/
slack/
client.ts # Block Kit renderer + Web API
interactivity.ts # Slack button click handler
portal/
static/
index.html # Responder SPA
app.js # Alpine.js portal logic
styles.css # Minimal styling
sdk/
client.ts # HCPClient class
index.ts # SDK exports + re-exported types
nanoclaw/
integration.ts # IPC task type + MCP tool docs
utils/
ulid.ts # ULID generation
sse.ts # SSE client registry + broadcast
scripts/
setup-key.ts # CLI to create API keys
test/
unit/
state-machine.test.ts
audit-store.test.ts
integration/
api.test.ts
e2e-flow.test.ts
| Script | Command | Description |
|---|---|---|
dev |
tsx watch src/index.ts |
Start with hot reload |
build |
tsup |
Build to dist/ |
start |
node dist/index.js |
Run production build |
test |
vitest run |
Run test suite |
test:watch |
vitest |
Run tests in watch mode |
setup-key |
tsx src/scripts/setup-key.ts |
Create an API key |
tsup produces two entry points:
dist/index.js— Server entry pointdist/sdk/index.js— SDK for agent-side consumption (importable ashcp/sdk)
Both include source maps and TypeScript declaration files.
MIT