Agentic Node
Communication Protocol
01 Overview
The Agentic Node Communication Protocol (ANCP) is an open, HTTP-based protocol that defines how autonomous software agents — called nodes — communicate with each other within and across the BizFirst platform. ANCP is the standardized language that allows every node to call, respond to, stream from, and coordinate with every other node, regardless of where they are hosted.
ANCP is not a new wire format. All messages use the platform-standard IEnvelope — the universal BizFirst message container. ANCP adds a metadata extension block (metadata["ncp"]) inside the envelope that carries protocol-specific fields like action names, routing information, and task identifiers.
What ANCP Provides
| Capability | Description |
|---|---|
| 4 message patterns | Fire-and-forget, Request-Reply, Streaming (SSE), Task-Start |
| 8 addressing modes | NodeId, ResId, DID, EVM address, GroupId, GroupResId, OperateID, Direct URL |
| 3 auth modes | JWT, API-Key, DID Proof — validated in priority order |
| Payment gating | EVM on-chain payment → signed receipt → capability access |
| Discovery | /.well-known/ncp.json — machine-readable capability manifest |
| Observability | OpenTelemetry spans + metrics on both caller and receiver sides |
| Group routing | RoundRobin, Random, FirstAvailable, Broadcast across node groups |
02 Design Goals
| Goal | Approach |
|---|---|
| Universal node interop | Any node can call any other node using a standard interface |
| No new wire format | Reuse IEnvelope — existing serialization, logging, and tooling applies |
| SRP compliance | One concern per file: AncpClient delegates to AncpEnvelopeBuilder, AncpEndpointCache, IAncpTransport, AncpObservabilityWriter |
| Production-ready defaults | In-memory stores ship as defaults; Redis/DB stores are drop-in replacements via TryAddSingleton |
| Security by default | Auth required on all non-system actions; DID proof audience pinned to endpoint URL |
| Testability | FakeAncpTransport eliminates HTTP in tests; NullAncpNodeCapability for light nodes |
| Autonomous economy | First-class payment gating: nodes price capabilities, accept crypto, issue signed receipts |
03 IEnvelope — Wire Format
Every ANCP message is a JSON object conforming to the IEnvelope schema. The same schema is used for requests, responses, SSE chunks, task-status envelopes, and error responses.
{
"meta": {
"id": "corr-uuid-here", // Correlation ID (required)
"topic": "ncp:7:42:call", // Routing topic
"timestamp": "2026-03-16T10:30:00Z", // ISO-8601 UTC
"nodeProtocol": "ncp", // MUST be "ncp" for ANCP messages
"protocol": null // Transport field — do NOT set to "ncp"
},
"body": {
"data": {
"metadata": {
"messageType": {
"type": "ncp",
"subType": "request-reply", // Message pattern
"handler": "AncpHandler"
},
"source": { "name": "CallerNode", "system": "ANCP" },
"extensions": {
"ncp": { // AncpMetadataExtension
"version": "1.0",
"action": "get-payroll-status",
"targetNodeId": 42,
"targetTenantId": 7,
"targetRole": "default",
"callerNodeId": 55,
"callerTenantId": 8,
"callerDid": "did:bizfirst:tenant8:node:55"
}
}
},
"data": { ... }, // Request payload or response data
"error": null // AncpError if call failed
}
}
}
meta.nodeProtocol must be set to "ncp" for all ANCP messages.meta.protocol is the transport layer field (e.g. "signal-r") and must NOT be set to "ncp".
AncpMetadataExtension Fields
| Field | Type | Direction | Description |
|---|---|---|---|
version | string | Both | Protocol version. Always "1.0". |
action | string | Both | Action name being invoked (e.g. "get-payroll-status"). |
targetNodeId | int? | Request | Target node's ProcessElementID. |
targetTenantId | int? | Request | Target node's tenant. |
targetRole | string | Request | Identity role requested on target. Default: "default". |
callerNodeId | int? | Request | Caller node's ProcessElementID. |
callerResId | string? | Request | Caller node's ResId (GUID). |
callerDid | string? | Request | Caller DID (for DID auth mode). |
callerEvmAddress | string? | Request | Caller's EVM wallet address. |
callerOperateId | string? | Request | Caller's OperateID. |
callerTenantId | int? | Request | Caller tenant. Used for cross-tenant identity checks. |
receiverNodeId | int? | Response | Receiver's nodeId. Set on all responses. |
durationMs | long? | Response | Server processing time in ms. |
taskId | string? | Response | Assigned task ID. Set on task-accepted responses. |
taskState | string? | Response | Task state: pending|running|completed|failed|cancelled. |
taskProgress | int? | Response | Task progress 0–100. |
taskStatusUrl | string? | Response | Relative URL for polling task status. |
sequence | int? | Response | Chunk sequence number in streaming responses. |
04 Node Addressing
NodeAddress identifies a target node or node group for an ANCP call. It is immutable, created exclusively through static factory methods, and carries exactly one active addressing mode per instance.
// NodeId — same server, same tenant
NodeAddress.FromNodeId(tenantId: 7, nodeId: 42)
// ResId — cross-server, same tenant
NodeAddress.FromResId(tenantId: 7, resId: Guid.Parse("..."))
// DID — cross-tenant, identity-based
NodeAddress.FromDid("did:bizfirst:tenant7:node:42")
// EVM address — blockchain identity
NodeAddress.FromEvmAddress("0xAbCd...1234", chainId: 137)
// Node group (round-robin by default)
NodeAddress.FromNodeGroupId(tenantId: 7, groupId: 10)
NodeAddress.FromNodeGroupResId(tenantId: 7, groupResId: Guid.Parse("..."))
// OperateID — federated operator identity
NodeAddress.FromOperateId("op:acme:billing-engine-1")
// Direct URL — bypass registry entirely
NodeAddress.FromDirectUrl("https://partner.example.com", nodeId: 42, tenantId: 7)
Addressing Modes
| Mode | Use Case | Registry Required |
|---|---|---|
NodeId | Same-server, same-tenant call. NodeId = ProcessElementID. | Yes (local lookup) |
ResId | Cross-server, same-tenant call via globally unique GUID. | Yes |
Did | Cross-tenant, identity-based. TenantId inferred from DID document. | Yes |
BlockchainAddress | Identifies a node by its EVM wallet. Chain-agnostic by default. | Yes |
NodeGroupId | Route to a node group by integer ID. Supports routing strategies. | Yes |
NodeGroupResId | Route to a cross-server node group by GUID. | Yes |
OperateID | Federated identity via OperateID.com. Enables cross-platform routing. | Yes |
Direct | Bypass registry entirely — caller provides full endpoint URL. | No |
Node Group Routing Strategies
| Strategy | Behaviour | Allowed Patterns |
|---|---|---|
RoundRobin | Cycle through online members in order. Thread-safe via atomic counter. | All 4 patterns |
Random | Pick a random online member each call. | All 4 patterns |
FirstAvailable | Use the first member with status "online". | All 4 patterns |
Broadcast | Fan out to ALL online members in parallel (Task.WhenAll). Per-member failures swallowed. | Fire-and-forget only |
05 Message Patterns
Caller sends a request and does not wait for a result. The node acknowledges receipt with 202 and processes asynchronously. No response body. Ideal for notifications, triggers, and event fanout.
Synchronous call-response. Caller blocks until the node returns a result. Response is a full IEnvelope with the result in body.data.data. Ideal for queries and commands with immediate results.
Node returns a sequence of chunks via Server-Sent Events. Each data: line is a complete IEnvelope. Terminates with a stream-complete envelope. Ideal for AI-generated content and large result sets.
Node starts a long-running background task and immediately returns a taskId. Caller polls GET /ncp/nodes/{id}/tasks/{taskId} for status. Ideal for payroll runs, reports, and AI agent workflows.
Fire-and-Forget — Request & Response
Request
POST /ncp/nodes/42/invoke
X-Ancp-Version: 1.0
Authorization: Bearer {jwt}
{
"meta": { "id": "corr-001", "nodeProtocol": "ncp", "timestamp": "..." },
"body": { "data": {
"metadata": {
"messageType": { "type": "ncp", "subType": "fire-and-forget" },
"extensions": { "ncp": { "version": "1.0", "action": "trigger-recalc" } }
},
"data": { "employeeId": 123 }
}}
}
Response
HTTP/1.1 202 Accepted (no body)
Request-Reply — Request & Response
Request
POST /ncp/nodes/42/invoke
X-Ancp-Version: 1.0
Authorization: Bearer {jwt}
{
"meta": { "id": "corr-002", "nodeProtocol": "ncp", "timestamp": "..." },
"body": { "data": {
"metadata": {
"messageType": { "type": "ncp", "subType": "request-reply" },
"extensions": { "ncp": { "version": "1.0", "action": "get-payroll-status",
"callerNodeId": 55, "callerTenantId": 8 } }
},
"data": { "employeeId": 123 }
}}
}
Response (200 OK)
HTTP/1.1 200 OK
X-Ancp-Version: 1.0
X-Ancp-Correlation-Id: corr-002
X-Ancp-Node-Id: 42
{
"meta": { "id": "corr-002", "nodeProtocol": "ncp", ... },
"body": { "data": {
"metadata": {
"messageType": { "type": "ncp", "subType": "response" },
"extensions": { "ncp": { "version": "1.0", "action": "get-payroll-status",
"receiverNodeId": 42, "durationMs": 12 } }
},
"data": { "employeeId": 123, "status": "Active", "lastRunAt": "2026-03-01T00:00:00Z" }
}}
}
Streaming (SSE) — Response Format
Each chunk is a data: SSE line containing a serialized IEnvelope with subType: "stream-chunk". The stream terminates with a stream-complete envelope.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
X-Ancp-Version: 1.0
event: chunk
data: {"meta":{"id":"corr-003","nodeProtocol":"ncp",...},"body":{"data":{"metadata":{"messageType":{"subType":"stream-chunk"},"extensions":{"ncp":{"sequence":1}}},"data":{"department":"Engineering","total":142000}}}}
event: chunk
data: {"meta":{"id":"corr-003","nodeProtocol":"ncp",...},"body":{"data":{"metadata":{"messageType":{"subType":"stream-chunk"},"extensions":{"ncp":{"sequence":2}}},"data":{"department":"Finance","total":89000}}}}
event: complete
data: {"meta":{"id":"corr-003","nodeProtocol":"ncp",...},"body":{"data":{"metadata":{"messageType":{"subType":"stream-complete"},"extensions":{"ncp":{"sequence":2,"durationMs":847}}}}}}
Task-Start — Accepted & Polling
Initial Response (202)
HTTP/1.1 202 Accepted
Location: /ncp/nodes/42/tasks/task-a1b2c3d4
{
"meta": { "id": "corr-004", "nodeProtocol": "ncp", ... },
"body": { "data": {
"metadata": {
"messageType": { "subType": "task-accepted" },
"extensions": { "ncp": { "taskId": "task-a1b2c3d4", "taskState": "pending",
"taskStatusUrl": "/ncp/nodes/42/tasks/task-a1b2c3d4" } }
}
}}
}
Poll Status — GET /ncp/nodes/42/tasks/task-a1b2c3d4
{
"meta": { ... },
"body": { "data": {
"metadata": {
"messageType": { "subType": "task-status" },
"extensions": { "ncp": { "taskId": "task-a1b2c3d4", "taskState": "running",
"taskProgress": 42 } }
}
}}
}
06 HTTP Transport
Required Headers
| Header | Direction | Value | Required |
|---|---|---|---|
X-Ancp-Version | Request | 1.0 | Always |
Authorization | Request | Bearer {jwt} | For JWT auth |
X-Ancp-Api-Key | Request | API key value | For API-Key auth |
X-Ancp-Did-Proof | Request | Compact JWT DID proof | For DID auth |
X-Ancp-Payment-Receipt | Request | {receiptId}:{signature} | For payment-gated actions |
X-Ancp-Version | Response | 1.0 | Always |
X-Ancp-Correlation-Id | Response | Correlation ID from request | On 200/202 with body |
X-Ancp-Node-Id | Response | Receiver's nodeId | On 200 responses |
Endpoint Map
| Method | Path | Description |
|---|---|---|
POST | /ncp/nodes/{nodeId:int}/invoke | Invoke any action. Handles all 4 patterns. |
GET | /ncp/nodes/{nodeId:int}/tasks/{taskId} | Poll task status. |
DELETE | /ncp/nodes/{nodeId:int}/tasks/{taskId} | Request task cancellation. |
GET | /.well-known/ncp.json | Discovery document (optional). |
07 Authentication
The receiver validates credentials in strict priority order: JWT → API-Key → DID Proof. The first matching credential wins. If none match, 401 Unauthorized is returned with no body (for security).
DID Proof Validation
The DID proof is a compact JWT signed by the caller's DID key. The receiver validates:
- JWT signature against the caller's DID document verification method
issmatches the caller's DIDaudMUST equal the receiver'sNodeEndpointUrl— prevents cross-node replay attacks- Not expired (uses
expclaim) - DID method is in
AllowedMethods
aud) MUST equal config.NodeEndpointUrl exactly. A proof issued for Node A cannot be replayed against Node B, even if the same caller DID is trusted on both nodes.
ACL Policy
After authentication, the receiver checks whether the caller's identity holds the required role for the requested pattern:
| Pattern | Required Role |
|---|---|
| request-reply | invoke |
| fire-and-forget | invoke |
| task-start | invoke |
| streaming | stream |
An open node (no ACL entries configured) allows all authenticated callers. DID-authenticated callers are also checked against explicit DID ACL entries.
08 AncpClient
The AncpClient is the caller-side API. Register it once as a Singleton and inject IAncpClient wherever you need to call another node.
// Registration
builder.Services.AddAncpClient(opts =>
{
opts.DefaultTenantId = 7;
opts.RegistryBaseUrl = "https://registry.bizfirst.internal";
opts.RequestReplyTimeout = TimeSpan.FromSeconds(30);
opts.Auth = new AncpAuthOptions { BearerToken = myJwtToken };
});
// Fire-and-forget
await _ancpClient.FireAndForgetAsync(
NodeAddress.FromNodeId(tenantId: 7, nodeId: 42),
action: "trigger-recalc",
payload: new { employeeId = 123 });
// Request-reply
var response = await _ancpClient.InvokeAsync<PayrollStatusResponse>(
NodeAddress.FromNodeId(7, 42),
action: "get-payroll-status",
payload: new { employeeId = 123 });
// Streaming
await foreach (var chunk in _ancpClient.StreamAsync<PayrollLine>(
NodeAddress.FromNodeId(7, 42), "stream-payroll-lines", req))
{
Console.WriteLine(chunk.Department);
}
// Task-start
var handle = await _ancpClient.StartTaskAsync(
NodeAddress.FromNodeId(7, 42),
action: "run-full-payroll",
payload: new { payrollPeriodId = "2026-03" });
var status = await _ancpClient.GetTaskStatusAsync<PayrollRunResult>(handle);
Endpoint Resolution
AncpClient resolves endpoints in this order:
- If
NodeAddress.Mode == Direct, use the provided URL directly — no registry call. - Check AncpEndpointCache (TTL-based, default 5 min). Return cached endpoint if fresh.
- Call IAncpRegistryClient →
GET {registryBaseUrl}/resolve?{queryString}. - Cache the result and proceed.
09 IAncpNodeCapability
The IAncpNodeCapability is the receiver-side API. Node authors register action handlers against it in ConfigureAncp(). The capability is injected as the protected Ancp property on BaseNodeExecutor.
protected override void ConfigureAncp(IAncpNodeCapability ncp)
{
// Request-reply
ncp.OnInvoke<GetStatusRequest, StatusResponse>(
"get-status",
async ctx => await _service.GetStatusAsync(ctx.Payload!.Id));
// Fire-and-forget
ncp.OnFireAndForget<TriggerRequest>(
"trigger",
ctx => _service.TriggerAsync(ctx.Payload!.Id));
// Streaming
ncp.OnStream<StreamRequest, LineItem>(
"stream-lines",
ctx => _service.StreamAsync(ctx.Payload!.PeriodId, ctx.CancellationToken));
// Long-running task
ncp.OnTask<RunRequest, RunResult>(
"run-full",
async (ctx, ct) => await _service.RunAsync(ctx.Payload!.PeriodId, ct));
}
Receiver Pipeline (7 Steps)
10 Registry & Heartbeat
The ANCP Registry is a central service that maps node identities to endpoint URLs. Nodes register themselves at startup via heartbeat and deregister on shutdown. The registry is queried by AncpClient during endpoint resolution.
Heartbeat Service
The AncpHeartbeatService is a BackgroundService that runs for the lifetime of the host. It periodically sends heartbeats for all registered ANCP nodes:
// Heartbeat payload (POST {registryBaseUrl}/heartbeat)
{
"nodeId": 42,
"tenantId": 7,
"endpoint": "https://server2.bizfirst.internal/ncp/nodes/42/invoke",
"resId": "a1b2c3d4-e5f6-...",
"did": "did:bizfirst:tenant7:node:42",
"supportedPatterns": ["fire-and-forget", "request-reply", "streaming", "task-start"],
"registeredActions": ["get-payroll-status", "run-full-payroll", "stream-payroll-lines"]
}
| Setting | Default | Description |
|---|---|---|
HeartbeatIntervalSeconds | 30 | How often heartbeats are sent. |
OfflineAfterSeconds | 90 | Registry marks node offline after this many seconds with no heartbeat. |
11 Discovery Document
When ExposeDiscoveryDocument = true (the default), the receiver exposes a machine-readable capability manifest at GET /.well-known/ncp.json:
{
"ncpVersion": "1.0",
"nodeId": 42,
"tenantId": 7,
"did": "did:bizfirst:tenant7:node:42",
"autonomousMode": true,
"aiModel": "claude-sonnet-4-6",
"authModes": ["jwt", "api-key"],
"systemActions": ["ancp.ping", "ancp.capabilities", "ancp.negotiate", "ancp.status"],
"nodes": [
{
"nodeId": 42,
"tenantId": 7,
"actions": [
{ "name": "get-payroll-status", "pattern": "request-reply", "requiresAuth": true },
{ "name": "run-full-payroll", "pattern": "task-start", "requiresAuth": true },
{ "name": "stream-payroll", "pattern": "streaming", "requiresAuth": true }
]
}
]
}
12 Observability
Activity Sources
| Source | Project | Spans |
|---|---|---|
BizFirst.Ancp.Client | Ancp.Client | ancp.call — one per outbound call |
BizFirst.Ancp.Node | Ancp.Node | ancp.receive — one per inbound call |
Meter: BizFirst.Ancp
| Metric | Type | Tags |
|---|---|---|
ancp.client.calls | Counter | action, pattern, status |
ancp.client.errors | Counter | action, error_type |
ancp.client.duration | Histogram (ms) | action, pattern |
ancp.client.chunks | Counter | action |
ancp.node.calls | Counter | nodeId, action, pattern |
ancp.node.errors | Counter | nodeId, error_type |
ancp.node.auth_failures | Counter | nodeId, auth_mode |
ancp.node.chunks_sent | Counter | nodeId, action |
13 Payment Flow
Autonomous nodes can gate access to capabilities behind EVM on-chain payments. The full flow is:
IAncpReceiptStore — Dual-Layer Replay Protection
Two independent layers prevent any payment from being used more than once:
| Layer | Key | Protects Against |
|---|---|---|
| Layer 1 | receiptId (UUID) | Receipt replay — reusing the same receipt for a second call |
| Layer 2 | txHash (on-chain) | TxHash replay — submitting the same tx hash twice to get two receipts |
The ancp.pay handler checks IsTxHashAlreadyRedeemedAsync before issuing, and calls MarkTxHashRedeemedAsync atomically with receipt creation.
14 DI Registration
Receiver (Node B)
// Program.cs
builder.Services.AddAncpNodeCapability(opts =>
{
opts.BaseUrl = "https://server2.bizfirst.internal";
opts.ExposeDiscoveryDocument = true;
opts.Registry.BaseUrl = "https://registry.bizfirst.internal";
opts.Registry.HeartbeatIntervalSeconds = 30;
});
app.UseAncp(); // Maps /ncp endpoints and /.well-known/ncp.json
Caller (Node A)
builder.Services.AddAncpClient(opts =>
{
opts.DefaultTenantId = 7;
opts.RegistryBaseUrl = "https://registry.bizfirst.internal";
opts.RequestReplyTimeout = TimeSpan.FromSeconds(30);
opts.StreamingTimeout = TimeSpan.FromSeconds(300);
});
Registered Services
| Interface | Implementation | Lifetime | Notes |
|---|---|---|---|
IAncpNodeRegistry | AncpNodeRegistry | Singleton | Node B |
IAncpAuthValidator | AncpAuthValidator | Singleton | Node B |
IJwtValidator | DefaultJwtValidator | Singleton | Replace for custom IdP |
IDidResolver | DefaultDidResolver | Singleton | Replace for production DID |
IAncpTaskStore | InMemoryAncpTaskStore | Singleton | Replace with Redis for multi-instance |
IAncpReceiptStore | InMemoryAncpReceiptStore | Singleton | Replace with persistent store |
IAncpRegistryClient | HttpAncpRegistryClient | Singleton | Both Node A and B |
IAncpTransport | HttpAncpTransport | Singleton | Node A; replace with FakeAncpTransport in tests |
IAncpClient | AncpClient | Singleton | Node A |
AncpEndpointCache | AncpEndpointCache | Singleton | Node A |
IHostedService | AncpHeartbeatService | Singleton | Node B |
15 BaseNodeExecutor Integration
ANCP integration is surfaced in BaseNodeExecutor as a partial class (BaseNodeExecutor.Ancp.cs):
public abstract partial class BaseNodeExecutor
{
// Never null — NullAncpNodeCapability for light nodes
protected IAncpNodeCapability Ancp { get; private set; } = NullAncpNodeCapability.Instance;
// Null if AddAncpClient() not registered
protected IAncpClient? AncpClient { get; private set; }
// Override to register handlers — called once at startup
protected virtual void ConfigureAncp(IAncpNodeCapability ncp) { }
// Called by ProcessEngine infrastructure — do not call directly
internal void InitializeAncp(IAncpNodeCapability capability, IAncpClient? client);
}
| Scenario | Ancp value | ConfigureAncp called |
|---|---|---|
| ANCP enabled, AddAncpNodeCapability registered | AncpNodeCapability | Yes, once |
| ANCP disabled (not registered) | NullAncpNodeCapability | No |
| Test host with mock | Custom mock | Yes |
16 Autonomous Nodes
An autonomous node operates independently 24/7. It processes ANCP calls without human intervention, advertises its capabilities, gates access behind payment, and makes outbound calls to other nodes. Autonomous nodes implement all 6 standard system actions.
System Actions
| Action | Pattern | Auth | Description |
|---|---|---|---|
ancp.ping | request-reply | No | Reachability and health check. Returns uptimeMs, version, echo. |
ancp.capabilities | request-reply | No | List all registered actions with pattern, auth, and price info. |
ancp.negotiate | request-reply | Optional | Pre-call negotiation. Returns supported/unsupported actions with payment terms. |
ancp.payment-info | request-reply | No | EVM address, accepted tokens, and full price list. |
ancp.pay | request-reply | Yes | Submit tx hash → receive signed receipt. Requires on-chain verification. |
ancp.status | request-reply | No | Runtime health, load, active tasks, AI model, autonomous mode flag. |
ancp. and are reserved. User-defined actions must not use this prefix.
Registering System Actions
ncp.AddSystemActions(nodeEntry, new AncpSystemActionsConfig
{
Version = "2.5.1",
Environment = "production",
IsAutonomous = true,
AiModel = "claude-sonnet-4-6",
PriceList = [
new AncpPriceDef { Action = "run-payroll", Pattern = "task-start", PriceUsdc = 10.00m },
new AncpPriceDef { Action = "stream-report", Pattern = "streaming", PriceUsdc = 2.50m }
],
Payment = new AncpPaymentServiceConfig
{
EvmAddress = "0xAbCd1234...",
ChainId = 137,
PreferredToken = "USDC"
}
});
17 Testing
FakeAncpTransport
Replace the real HTTP transport in tests. No HTTP calls are made.
var fakeTransport = new FakeAncpTransport();
// Setup responses
fakeTransport.SetupResponse(
urlPattern: "/invoke",
responseData: new PayrollStatusResponse { Status = "Active" });
fakeTransport.SetupStream(
urlPattern: "/invoke",
chunks: [new PayrollLine { Dept = "Eng", Total = 142_000 }]);
fakeTransport.SetupError(
urlPattern: "/invoke",
error: new HttpRequestException("Network failure"));
// After the test
Assert.Single(fakeTransport.CapturedRequests);
Assert.Contains("get-payroll-status", fakeTransport.CapturedRequests[0].Body);
Testing Action Handlers Directly
var taskStore = new InMemoryAncpTaskStore();
var cap = new AncpNodeCapability(taskStore);
var node = new PayrollEngineNode(mockPayrollService, mockTaxCalc);
node.InitializeAncp(cap, ancpClient: null);
var ctx = new AncpActionContext
{
Action = "get-payroll-status",
Pattern = "request-reply",
Payload = JsonSerializer.SerializeToElement(new { employeeId = 42 }),
CallerIdentity = new AncpCallerIdentity { Mode = AncpAuthMode.ApiKey, Roles = ["invoke"] }
};
var result = await cap.DispatchAsync(ctx);
Assert.Equal(AncpDispatchResultKind.Response, result.Kind);
18 Error Codes
| Code | HTTP | Meaning |
|---|---|---|
INVALID_VERSION | 400 | X-Ancp-Version header missing or value is not "1.0". |
INVALID_ENVELOPE | 400 | Request body is not a valid IEnvelope JSON, or action/subType are missing. |
NODE_NOT_FOUND | 404 | No ANCP-enabled node with the given nodeId is registered on this host. |
ACTION_NOT_FOUND | 404 | The action name is not registered on this node. |
PATTERN_MISMATCH | 422 | The action exists but was registered with a different pattern than what was requested. |
TASK_NOT_FOUND | 404 | The taskId is not found in the task store. |
INVOKE_ERROR | 500 | Unhandled exception in the action handler. |
AUTH_FAILED | 401 | No auth credentials provided, or credentials are invalid. No body. |
TX_NOT_CONFIRMED | 200* | Payment tx has insufficient confirmations. Receipt not issued. (*ancp.pay returns 200 with error body) |
AMOUNT_MISMATCH | 200* | Payment amount received on-chain does not match the required price. |
TX_ALREADY_REDEEMED | 200* | The tx hash was already used to issue a receipt (replay protection). |
19 HTTP Status Code Reference
| Status | Pattern | Meaning |
|---|---|---|
| 200 OK | request-reply, streaming | Success. Body is IEnvelope (or SSE stream). |
| 202 Accepted | fire-and-forget | Acknowledged. No body. |
| 202 Accepted | task-start | Task queued. Body is task-accepted IEnvelope with taskId. |
| 400 Bad Request | All | Invalid version header or malformed IEnvelope. Body: {"error":{"code":"...","message":"..."}}. |
| 401 Unauthorized | All | Auth failure. No body. |
| 403 Forbidden | All | ACL check failed — caller lacks required role. |
| 404 Not Found | All | Node or action not found. Body: error object. |
| 422 Unprocessable | All | Pattern mismatch. Body: error object with expected pattern. |
| 500 Internal Error | All | Unhandled exception in handler. Body: error object. |
20 Changelog
| Version | Date | Changes |
|---|---|---|
| 0.9.0 | 2026-03-16 | Initial draft. 4 message patterns, 8 addressing modes, 3 auth modes, payment gating, discovery document, OpenTelemetry observability, FakeAncpTransport test double, BaseNodeExecutor integration, autonomous node system actions. |
BizFirst.Ancp.Abstractions — interfaces, DTOs, IEnvelopeBizFirst.Ancp.Client — AncpClient, transport, registry clientBizFirst.Ancp.Node — receiver pipeline, auth, discovery, heartbeat
21 Contributors
The following individuals contributed to the design, architecture, and implementation of the ANCP specification and BizFirst reference implementation.
| # | Name | Role |
|---|---|---|
| 1 | Binoy Jose | Principal Architect |
| 2 | Shilja Jose | Technical Architect |
| 3 | Dhanya Joseph | Product Owner |
| 4 | Maha Siva | Junior Engineer |
| 5 | Sabarinath | Software Engineer |
| 6 | Cijo Jose | Technical Architect |
| 7 | Saji Jose | Technical Architect |
| 8 | Anit M George | Software Engineer |
| 9 | Ahsan P A | Software Engineer |
| 10 | Aswathy Raj | Technical Lead |
| 11 | Namita Jos | Technical Lead |
| 12 | Benoy Varghese | Project Management |
| 12 | Jonathon Chambless | Business - Real Estate |