SPECIFICATION v1.0.2

Agentic Node
Communication Protocol

ANCP — BizFirst Reference Implementation
Version
0.9.0
Status
Draft
Date
2026-03-16
Platform
.NET 9 / C# 13
Wire Format
IEnvelope (JSON)
Transport
HTTP/HTTPS + SSE

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.

Part of the BizFirst Discovery Specification
ANCP is a sub-specification of the broader BizFirst Discovery Specification — BizFirst's overarching data formatting and inter-service communication standard. The Discovery Specification defines the canonical envelope format (IEnvelope), service discovery contracts, capability manifests, and the extensibility model that ANCP builds upon. All ANCP messages are valid Discovery Specification messages; ANCP adds the agent-to-agent coordination layer on top.
Key Principle
ANCP is an application-layer protocol over HTTP. Every ANCP call is a standard HTTP request to a well-known endpoint. There is no custom TCP layer, no binary framing, and no proprietary transport — just JSON over HTTPS.

What ANCP Provides

CapabilityDescription
4 message patternsFire-and-forget, Request-Reply, Streaming (SSE), Task-Start
8 addressing modesNodeId, ResId, DID, EVM address, GroupId, GroupResId, OperateID, Direct URL
3 auth modesJWT, API-Key, DID Proof — validated in priority order
Payment gatingEVM on-chain payment → signed receipt → capability access
Discovery/.well-known/ncp.json — machine-readable capability manifest
ObservabilityOpenTelemetry spans + metrics on both caller and receiver sides
Group routingRoundRobin, Random, FirstAvailable, Broadcast across node groups

02 Design Goals

GoalApproach
Universal node interopAny node can call any other node using a standard interface
No new wire formatReuse IEnvelope — existing serialization, logging, and tooling applies
SRP complianceOne concern per file: AncpClient delegates to AncpEnvelopeBuilder, AncpEndpointCache, IAncpTransport, AncpObservabilityWriter
Production-ready defaultsIn-memory stores ship as defaults; Redis/DB stores are drop-in replacements via TryAddSingleton
Security by defaultAuth required on all non-system actions; DID proof audience pinned to endpoint URL
TestabilityFakeAncpTransport eliminates HTTP in tests; NullAncpNodeCapability for light nodes
Autonomous economyFirst-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
    }
  }
}
Important: protocol vs nodeProtocol
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

FieldTypeDirectionDescription
versionstringBothProtocol version. Always "1.0".
actionstringBothAction name being invoked (e.g. "get-payroll-status").
targetNodeIdint?RequestTarget node's ProcessElementID.
targetTenantIdint?RequestTarget node's tenant.
targetRolestringRequestIdentity role requested on target. Default: "default".
callerNodeIdint?RequestCaller node's ProcessElementID.
callerResIdstring?RequestCaller node's ResId (GUID).
callerDidstring?RequestCaller DID (for DID auth mode).
callerEvmAddressstring?RequestCaller's EVM wallet address.
callerOperateIdstring?RequestCaller's OperateID.
callerTenantIdint?RequestCaller tenant. Used for cross-tenant identity checks.
receiverNodeIdint?ResponseReceiver's nodeId. Set on all responses.
durationMslong?ResponseServer processing time in ms.
taskIdstring?ResponseAssigned task ID. Set on task-accepted responses.
taskStatestring?ResponseTask state: pending|running|completed|failed|cancelled.
taskProgressint?ResponseTask progress 0–100.
taskStatusUrlstring?ResponseRelative URL for polling task status.
sequenceint?ResponseChunk 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

NodeId ResId Did BlockchainAddress NodeGroupId NodeGroupResId OperateID Direct
ModeUse CaseRegistry Required
NodeIdSame-server, same-tenant call. NodeId = ProcessElementID.Yes (local lookup)
ResIdCross-server, same-tenant call via globally unique GUID.Yes
DidCross-tenant, identity-based. TenantId inferred from DID document.Yes
BlockchainAddressIdentifies a node by its EVM wallet. Chain-agnostic by default.Yes
NodeGroupIdRoute to a node group by integer ID. Supports routing strategies.Yes
NodeGroupResIdRoute to a cross-server node group by GUID.Yes
OperateIDFederated identity via OperateID.com. Enables cross-platform routing.Yes
DirectBypass registry entirely — caller provides full endpoint URL.No

Node Group Routing Strategies

StrategyBehaviourAllowed Patterns
RoundRobinCycle through online members in order. Thread-safe via atomic counter.All 4 patterns
RandomPick a random online member each call.All 4 patterns
FirstAvailableUse the first member with status "online".All 4 patterns
BroadcastFan out to ALL online members in parallel (Task.WhenAll). Per-member failures swallowed.Fire-and-forget only

05 Message Patterns

Fire-and-Forget
POST /ncp/nodes/{id}/invoke → 202 Accepted

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.

Request-Reply
POST /ncp/nodes/{id}/invoke → 200 OK + IEnvelope

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.

Streaming (SSE)
POST /ncp/nodes/{id}/invoke → 200 + text/event-stream

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.

Task-Start
POST /ncp/nodes/{id}/invoke → 202 + task-accepted IEnvelope

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

HeaderDirectionValueRequired
X-Ancp-VersionRequest1.0Always
AuthorizationRequestBearer {jwt}For JWT auth
X-Ancp-Api-KeyRequestAPI key valueFor API-Key auth
X-Ancp-Did-ProofRequestCompact JWT DID proofFor DID auth
X-Ancp-Payment-ReceiptRequest{receiptId}:{signature}For payment-gated actions
X-Ancp-VersionResponse1.0Always
X-Ancp-Correlation-IdResponseCorrelation ID from requestOn 200/202 with body
X-Ancp-Node-IdResponseReceiver's nodeIdOn 200 responses

Endpoint Map

MethodPathDescription
POST/ncp/nodes/{nodeId:int}/invokeInvoke 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.jsonDiscovery 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).

Incoming Request │ ├─ Authorization: Bearer {token}? → JWT validation (IAncpAuthValidator) │ ├─ Valid → AncpCallerIdentity (Mode=Jwt, Roles=[...]) │ └─ Invalid → AncpAuthFailedException → 401 │ ├─ X-Ancp-Api-Key header present? → ApiKey lookup in AncpApiKeyConfig.KeyToRoles │ ├─ Known key → AncpCallerIdentity (Mode=ApiKey, Roles=[...]) │ └─ Unknown key → AncpAuthFailedException → 401 │ └─ X-Ancp-Did-Proof header present? → DID proof validation ├─ Valid (aud = NodeEndpointUrl) → AncpCallerIdentity (Mode=Did, Roles=[]) └─ Invalid / wrong aud → AncpAuthFailedException → 401

DID Proof Validation

The DID proof is a compact JWT signed by the caller's DID key. The receiver validates:

  1. JWT signature against the caller's DID document verification method
  2. iss matches the caller's DID
  3. aud MUST equal the receiver's NodeEndpointUrl — prevents cross-node replay attacks
  4. Not expired (uses exp claim)
  5. DID method is in AllowedMethods
Security: Cross-Node Replay Prevention
The DID proof audience (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:

PatternRequired Role
request-replyinvoke
fire-and-forgetinvoke
task-startinvoke
streamingstream

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:

  1. If NodeAddress.Mode == Direct, use the provided URL directly — no registry call.
  2. Check AncpEndpointCache (TTL-based, default 5 min). Return cached endpoint if fresh.
  3. Call IAncpRegistryClientGET {registryBaseUrl}/resolve?{queryString}.
  4. 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)

POST /ncp/nodes/{nodeId}/invoke │ 1. X-Ancp-Version check → 400 INVALID_VERSION 2. IEnvelope deserialization → 400 INVALID_ENVELOPE 3. Node lookup in registry → 404 NODE_NOT_FOUND 4. Authentication (JWT/Key/DID) → 401 (no body) 5. ACL check → 403 6. Action dispatch → 404 ACTION_NOT_FOUND / 422 PATTERN_MISMATCH 7. Format IEnvelope response → 200 / 202 / SSE

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"]
}
SettingDefaultDescription
HeartbeatIntervalSeconds30How often heartbeats are sent.
OfflineAfterSeconds90Registry 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

SourceProjectSpans
BizFirst.Ancp.ClientAncp.Clientancp.call — one per outbound call
BizFirst.Ancp.NodeAncp.Nodeancp.receive — one per inbound call

Meter: BizFirst.Ancp

MetricTypeTags
ancp.client.callsCounteraction, pattern, status
ancp.client.errorsCounteraction, error_type
ancp.client.durationHistogram (ms)action, pattern
ancp.client.chunksCounteraction
ancp.node.callsCounternodeId, action, pattern
ancp.node.errorsCounternodeId, error_type
ancp.node.auth_failuresCounternodeId, auth_mode
ancp.node.chunks_sentCounternodeId, action

13 Payment Flow

Autonomous nodes can gate access to capabilities behind EVM on-chain payments. The full flow is:

Node A (caller) Node B (autonomous receiver) │ │─── ancp.capabilities ─────────────────► Discover available actions + prices │◄── action list with priceUsdc ───────── │ │─── ancp.payment-info ──────────────────► Get EVM wallet address │◄── { evmAddress, acceptedTokens } ────── │ │ [Send USDC on-chain to 0xAbCd...] │ │─── ancp.pay ──────────────────────────► Submit tx hash │ { txHash, forAction, amount } Verify on-chain (3 confirmations) │◄── { receiptId, validUntil, signature } Issue signed receipt │ │─── run-payroll ─────────────────────► Paid action call │ X-Ancp-Payment-Receipt: rcpt-...:sig Receipt validated → dispatch │◄── 202 Accepted + taskId ─────────────

IAncpReceiptStore — Dual-Layer Replay Protection

Two independent layers prevent any payment from being used more than once:

LayerKeyProtects Against
Layer 1receiptId (UUID)Receipt replay — reusing the same receipt for a second call
Layer 2txHash (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

InterfaceImplementationLifetimeNotes
IAncpNodeRegistryAncpNodeRegistrySingletonNode B
IAncpAuthValidatorAncpAuthValidatorSingletonNode B
IJwtValidatorDefaultJwtValidatorSingletonReplace for custom IdP
IDidResolverDefaultDidResolverSingletonReplace for production DID
IAncpTaskStoreInMemoryAncpTaskStoreSingletonReplace with Redis for multi-instance
IAncpReceiptStoreInMemoryAncpReceiptStoreSingletonReplace with persistent store
IAncpRegistryClientHttpAncpRegistryClientSingletonBoth Node A and B
IAncpTransportHttpAncpTransportSingletonNode A; replace with FakeAncpTransport in tests
IAncpClientAncpClientSingletonNode A
AncpEndpointCacheAncpEndpointCacheSingletonNode A
IHostedServiceAncpHeartbeatServiceSingletonNode 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);
}
ScenarioAncp valueConfigureAncp called
ANCP enabled, AddAncpNodeCapability registeredAncpNodeCapabilityYes, once
ANCP disabled (not registered)NullAncpNodeCapabilityNo
Test host with mockCustom mockYes

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

ActionPatternAuthDescription
ancp.pingrequest-replyNoReachability and health check. Returns uptimeMs, version, echo.
ancp.capabilitiesrequest-replyNoList all registered actions with pattern, auth, and price info.
ancp.negotiaterequest-replyOptionalPre-call negotiation. Returns supported/unsupported actions with payment terms.
ancp.payment-inforequest-replyNoEVM address, accepted tokens, and full price list.
ancp.payrequest-replyYesSubmit tx hash → receive signed receipt. Requires on-chain verification.
ancp.statusrequest-replyNoRuntime health, load, active tasks, AI model, autonomous mode flag.
System Action Prefix
System actions are prefixed 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

CodeHTTPMeaning
INVALID_VERSION400X-Ancp-Version header missing or value is not "1.0".
INVALID_ENVELOPE400Request body is not a valid IEnvelope JSON, or action/subType are missing.
NODE_NOT_FOUND404No ANCP-enabled node with the given nodeId is registered on this host.
ACTION_NOT_FOUND404The action name is not registered on this node.
PATTERN_MISMATCH422The action exists but was registered with a different pattern than what was requested.
TASK_NOT_FOUND404The taskId is not found in the task store.
INVOKE_ERROR500Unhandled exception in the action handler.
AUTH_FAILED401No auth credentials provided, or credentials are invalid. No body.
TX_NOT_CONFIRMED200*Payment tx has insufficient confirmations. Receipt not issued. (*ancp.pay returns 200 with error body)
AMOUNT_MISMATCH200*Payment amount received on-chain does not match the required price.
TX_ALREADY_REDEEMED200*The tx hash was already used to issue a receipt (replay protection).

19 HTTP Status Code Reference

StatusPatternMeaning
200 OKrequest-reply, streamingSuccess. Body is IEnvelope (or SSE stream).
202 Acceptedfire-and-forgetAcknowledged. No body.
202 Acceptedtask-startTask queued. Body is task-accepted IEnvelope with taskId.
400 Bad RequestAllInvalid version header or malformed IEnvelope. Body: {"error":{"code":"...","message":"..."}}.
401 UnauthorizedAllAuth failure. No body.
403 ForbiddenAllACL check failed — caller lacks required role.
404 Not FoundAllNode or action not found. Body: error object.
422 UnprocessableAllPattern mismatch. Body: error object with expected pattern.
500 Internal ErrorAllUnhandled exception in handler. Body: error object.

20 Changelog

VersionDateChanges
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 Reference Implementation
This specification is implemented across three .NET 9 library projects:
BizFirst.Ancp.Abstractions — interfaces, DTOs, IEnvelope
BizFirst.Ancp.Client — AncpClient, transport, registry client
BizFirst.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