ANCP Getting Started
The Agentic Node Communication Protocol lets AI agents, services, and frontends communicate through a unified protocol supporting 4 message patterns, 3 auth modes, and built-in payment gating. Part of the BizFirst Discovery Specification.
Overview
ANCP is a wire-level communication layer for agentic systems. Every message —
request, response, stream chunk, task update — is carried in a standard
IEnvelope with ANCP metadata in the extensions block.
4 Message Patterns
| Pattern | HTTP | Use Case |
|---|---|---|
request-reply | POST → 200 | Synchronous query/command |
fire-and-forget | POST → 202 | Event / log / one-way notification |
streaming | POST → SSE | AI token streaming, real-time reports |
task-start | POST → 202 + taskId | Long-running background jobs with polling |
8 Addressing Modes
Nodes are addressed by NodeId, ResId, DID,
BlockchainAddress, NodeGroupId, NodeGroupResId,
OperateID, or Direct URL.
Installation
NuGet (C#)
Add the projects to your solution or reference them as NuGet packages:
# Receiver (node hosting)
dotnet add reference BizFirst.Ancp.Node
# Client (call remote nodes)
dotnet add reference BizFirst.Ancp.Client
npm (TypeScript / React)
npm install @bizfirst/ancp-core
npm install @bizfirst/ancp-react # React hooks (optional)
Key Concepts
IEnvelope
Every ANCP message wraps its payload in an IEnvelope.
ANCP metadata (action, pattern, caller identity, task state) lives in
body.data.metadata.extensions["ncp"]. The envelope format is defined
by the BizFirst Discovery Specification.
Node vs Client
| Role | Package | Responsibility |
|---|---|---|
| Node (receiver) | BizFirst.Ancp.Node | Hosts actions, validates auth, dispatches to handlers |
| Client (caller) | BizFirst.Ancp.Client | Builds envelopes, resolves addresses, sends HTTP requests |
IAncpNodeCapability
The fluent API for registering action handlers on a node. Each action maps to exactly one message pattern and one handler function.
Server — Project Setup
In your ASP.NET Core application (using Microsoft.NET.Sdk.Web),
add the ANCP receiver via the DI extension in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAncp(ancp =>
{
ancp.AddNode(nodeId: 42, tenantId: 7, node =>
{
node.Auth.UseApiKey(keys =>
{
keys.Add("bfk_secret123", ["invoke"]);
});
node.Capability
.OnInvoke<MyRequest, MyResponse>("my-action", ctx =>
Task.FromResult(new MyResponse { Result = "ok" }));
});
});
var app = builder.Build();
app.MapAncpReceiver(); // Registers /ncp/nodes/{nodeId}/invoke etc.
app.Run();
Server — Register a Node
Each call to AddNode() registers a logical ANCP node with its own config:
ancp.AddNode(nodeId: 100, tenantId: 1, node =>
{
// Auth
node.Auth.UseJwt(issuer: "https://auth.bizfirst.internal",
audience: "ancp");
// Node metadata for system actions
node.SystemActions.Configure(cfg =>
{
cfg.Version = "2.1.0";
cfg.Environment = "production";
cfg.AiModel = "claude-sonnet-4-6";
cfg.IsAutonomous = true;
});
// Handlers
node.Capability
.OnInvoke<OrderQuery, OrderResult>("get-order", HandleGetOrder)
.OnFireAndForget<LogEvent>("log-event", HandleLogEvent)
.OnStream<ReportRequest, ReportChunk>("generate-report", HandleReport)
.OnTask<PayrollInput, PayrollResult>("run-payroll", HandlePayroll);
});
Server — Message Pattern Handlers
request-reply
private static Task<OrderResult> HandleGetOrder(AncpActionContext<OrderQuery> ctx)
{
var order = _orderService.Get(ctx.Payload.OrderId);
return Task.FromResult(new OrderResult
{
OrderId = order.Id,
Status = order.Status,
Total = order.Total
});
}
fire-and-forget
private static Task HandleLogEvent(AncpActionContext<LogEvent> ctx)
{
_logger.LogInformation("Event: {Event} User: {User}",
ctx.Payload.EventType, ctx.CallerIdentity.Subject);
return Task.CompletedTask;
}
streaming
private static async IAsyncEnumerable<ReportChunk> HandleReport(
AncpActionContext<ReportRequest> ctx)
{
await foreach (var row in _reportService.StreamAsync(ctx.Payload.Filters))
{
yield return new ReportChunk { Row = row };
await Task.Delay(10); // optional pacing
}
}
task-start (long-running)
private static async Task<PayrollResult> HandlePayroll(
AncpActionContext<PayrollInput> ctx,
CancellationToken ct)
{
// Use ctx.TaskContext to report progress (if injected)
var result = await _payrollEngine.RunAsync(ctx.Payload.Month, ct);
return new PayrollResult
{
EmployeesProcessed = result.Count,
TotalPaid = result.Total
};
}
Task handlers receive a CancellationToken that is triggered when the task is cancelled via DELETE /ncp/nodes/{id}/tasks/{taskId}. Always pass it through to database / HTTP calls.
Server — Authentication
Each node can accept multiple auth modes simultaneously. The first matching credential wins.
JWT (Bearer token)
node.Auth.UseJwt(cfg =>
{
cfg.Issuer = "https://auth.bizfirst.internal";
cfg.Audience = "ancp";
cfg.Algorithms = ["RS256"];
// JWK JSON or PEM string for signature validation
cfg.PublicKeyOrJwksUrl = File.ReadAllText("keys/public.jwk");
});
API Key
node.Auth.UseApiKey(cfg =>
{
// Map keys to roles. Hash keys in production.
cfg.Keys["bfk_prod_abc123"] = ["invoke", "stream"];
cfg.Keys["bfk_admin_xyz"] = ["invoke", "admin"];
});
DID Proof (Cross-node)
node.Auth.UseDid(cfg =>
{
cfg.AllowedMethods = ["bizfirst", "key", "web"];
cfg.MaxProofAgeMinutes = 5;
cfg.NodeEndpointUrl = "https://api.bizfirst.internal";
});
// Register a real IDidResolver for production DID auth
services.AddSingleton<IDidResolver, BizFirstDidResolver>();
Always configure a PublicKeyOrJwksUrl (JWK JSON) for JWT mode in production.
Without it the DefaultJwtValidator skips signature verification.
For DID auth, register a real IDidResolver — the default resolver always returns null.
Server — Payment Gating
Gate actions behind on-chain USDC payments using the ancp.pay flow:
node.Capability
.AddSystemActions(nodeEntry, systemActionsConfig)
.AddPaymentAction(nodeEntry,
config: new AncpPaymentGateConfig
{
EvmAddress = "0xYourNodeWallet",
RequiredConfirmations = 3,
SigningKeySecret = secrets["ancp-signing-key"],
PriceList = [
new PaymentPriceDef
{
Action = "run-payroll",
PriceUsdc = 5.00m,
ReceiptValiditySec = 300
}
]
},
receiptStore: services.GetRequiredService<IAncpReceiptStore>(),
web3: services.GetRequiredService<IWeb3Provider>());
Clients pay by calling ancp.pay first, then pass the received receiptId in their action calls.
Server — Full DI Example
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAncp(ancp =>
{
ancp.AddNode(nodeId: 42, tenantId: 7, node =>
{
node.Auth.UseJwt(issuer: "https://auth.co", audience: "ancp");
node.Capability
.OnInvoke<PingRequest, PingResponse>("my-ping",
ctx => Task.FromResult(new PingResponse { Echo = ctx.Payload.Echo }))
.OnStream<SearchRequest, SearchResult>("search", HandleSearch);
});
})
.AddSingleton<IAncpReceiptStore, InMemoryAncpReceiptStore>()
.AddSingleton<IDidResolver, DefaultDidResolver>();
var app = builder.Build();
app.UseHttpsRedirection();
app.MapAncpReceiver();
app.Run();
Client — AncpClient Setup (C#)
// Program.cs
builder.Services.AddAncpClient(options =>
{
options.BaseUrl = "https://api.bizfirst.internal";
options.NodeId = 42;
options.TenantId = 7;
options.Auth.ApiKey = "bfk_prod_abc123";
});
// Then inject IAncpClient wherever needed
public class OrderService(IAncpClient ancp)
{
public async Task<OrderResult> GetOrderAsync(string orderId)
=> await ancp.InvokeAsync<OrderQuery, OrderResult>(
"get-order", new OrderQuery { OrderId = orderId });
}
Client — Invoke Actions (C#)
Request-Reply
var result = await ancpClient.InvokeAsync<OrderQuery, OrderResult>(
action: "get-order",
payload: new OrderQuery { OrderId = "ORD-123" });
Fire-and-Forget
await ancpClient.FireAndForgetAsync<LogEvent>(
"log-event",
new LogEvent { EventType = "user-login", UserId = 7 });
Addressing — By NodeId
var address = NodeAddress.FromNodeId(tenantId: 7, nodeId: 42);
var result = await ancpClient.InvokeAsync<Q, R>("action", payload, address);
Addressing — By DID
var address = NodeAddress.FromDid("did:bizfirst:node:42");
var result = await ancpClient.InvokeAsync<Q, R>("action", payload, address);
Client — Streaming (C#)
await foreach (var chunk in
ancpClient.StreamAsync<ReportRequest, ReportChunk>(
"generate-report",
new ReportRequest { Year = 2026 }))
{
Console.WriteLine(chunk.Row);
}
Client — Long-Running Tasks (C#)
// Start the task — returns immediately with a taskId
var taskId = await ancpClient.StartTaskAsync<PayrollInput>(
"run-payroll",
new PayrollInput { Month = "2026-03" });
// Poll until terminal state (Completed / Failed / Cancelled)
var finalStatus = await ancpClient.WaitForTaskAsync<PayrollResult>(
taskId,
pollIntervalMs: 2000,
timeoutMs: 120_000);
if (finalStatus.State == AncpTaskState.Completed)
Console.WriteLine($"Paid {finalStatus.Result.EmployeesProcessed} employees");
// Cancel a running task
await ancpClient.CancelTaskAsync(taskId);
TypeScript — Install Packages
npm install @bizfirst/ancp-core # Core client — browser + Node.js
npm install @bizfirst/ancp-react # React 18 hooks
TypeScript — AncpClient Usage
Request-Reply
import { AncpClient } from '@bizfirst/ancp-core';
const client = new AncpClient({
baseUrl: 'https://api.bizfirst.internal',
nodeId: 42,
auth: { apiKey: 'bfk_...' },
});
const result = await client.invoke('get-order', { orderId: 'ORD-123' });
console.log(result.status);
Streaming
for await (const chunk of client.stream('generate-report', { year: 2026 })) {
console.log(chunk);
}
Long Tasks
const taskId = await client.startTask('run-payroll', { month: '2026-03' });
const finalStatus = await client.waitForTask(taskId, { pollIntervalMs: 2000 });
console.log(finalStatus.state); // 'Completed'
System Actions
const ping = await client.ping();
const caps = await client.getCapabilities();
const doc = await client.discover(); // /.well-known/ncp.json
React — Provider Setup
Wrap your app (or a subtree) with <AncpProvider>:
import { AncpProvider } from '@bizfirst/ancp-react';
function App() {
return (
<AncpProvider options={{
baseUrl: 'https://api.bizfirst.internal',
nodeId: 42,
auth: { jwtToken: token },
}}>
<YourApp />
</AncpProvider>
);
}
// For testing — inject a FakeAncpClient
import { FakeAncpClient } from '@bizfirst/ancp-core';
const fake = new FakeAncpClient();
fake.setupResponse('get-order', { status: 'Active' });
<AncpProvider client={fake}><MyComponent /></AncpProvider>
React — Hooks Reference
useAncpInvoke — Request-Reply
const { invoke, isLoading, data, error, reset } =
useAncpInvoke<OrderQuery, OrderResult>('get-order');
const handleClick = async () => {
const result = await invoke({ orderId: 'ORD-123' });
console.log(result.status);
};
useAncpStream — Streaming
const { start, stop, chunks, isStreaming, isDone, error } =
useAncpStream<ReportRequest, ReportChunk>('generate-report');
await start({ year: 2026 });
// chunks[] updates in real-time as SSE data arrives
useAncpTask — Long Tasks
const { run, status, isPolling, error } =
useAncpTask<PayrollInput>('run-payroll');
const handleRun = async () => {
const finalStatus = await run(
{ month: '2026-03' },
{ pollIntervalMs: 2000, timeoutMs: 120_000 }
);
if (finalStatus.state === 'Completed') showSuccess();
};
useAncpCapabilities — Discovery
const { actions, payableActions, isLoading, refresh } = useAncpCapabilities();
return (
<ul>
{actions.map(a => <li key={a.action}>{a.action} ({a.pattern})</li>)}
</ul>
);
useAncpPing — Reachability
const { isReachable, isLoading } = useAncpPing();
if (!isLoading && !isReachable) return <NodeOfflineBanner />;
Auth Modes Reference
| Mode | Header | Priority | Use Case |
|---|---|---|---|
| JWT | Authorization: Bearer <token> | 1st (highest) | Human users, external IdPs (Auth0, Keycloak) |
| API Key | X-Ancp-Api-Key: bfk_... | 2nd | Service-to-service, CI/CD pipelines |
| DID Proof | X-Ancp-Did-Proof: <jwt> | 3rd | Cross-node calls where caller has a DID |
DID proofs are JWT tokens signed by the caller's DID key. The audience MUST equal the target node's endpoint URL to prevent replay attacks.
System Actions Reference
All nodes automatically expose these ancp.* system actions:
| Action | Pattern | Description |
|---|---|---|
ancp.ping | request-reply | Health check — returns nodeId, uptime, version |
ancp.capabilities | request-reply | Lists all registered actions with patterns and pricing |
ancp.negotiate | request-reply | Pre-call capability negotiation — checks if actions exist |
ancp.payment-info | request-reply | Returns node's EVM address, accepted tokens, price list |
ancp.pay | request-reply | Submit a payment txHash and receive a signed receipt |
ancp.status | request-reply | Operational status — environment, model, accepting calls |
Discovery document at GET /.well-known/ncp.json lists all nodes and their capabilities.
Testing
C# — Unit Tests with Mock
var mockStore = new Mock<IAncpTaskStore>();
var capability = new AncpNodeCapability(mockStore.Object);
capability.OnInvoke<Req, Res>("my-action",
ctx => Task.FromResult(new Res { Ok = true }));
var result = await capability.DispatchAsync(new AncpActionContext
{
Action = "my-action",
Pattern = "request-reply",
Payload = JsonSerializer.SerializeToElement(new Req())
});
Assert.Equal(AncpDispatchResultKind.Response, result.Kind);
TypeScript — FakeAncpClient
import { FakeAncpClient } from '@bizfirst/ancp-core';
const fake = new FakeAncpClient();
fake.setupResponse('get-order', { status: 'Active', total: 99.99 });
fake.setupError('bad-action', new AncpError('Unauthorized', 401));
fake.setupStream('generate-report', [chunk1, chunk2, chunk3]);
const result = await fake.invoke('get-order', { orderId: 'ORD-1' });
// Assert what was called
expect(fake.capturedCalls[0].action).toBe('get-order');
React — Testing with FakeAncpClient
import { render } from '@testing-library/react';
import { FakeAncpClient } from '@bizfirst/ancp-core';
import { AncpProvider } from '@bizfirst/ancp-react';
const fake = new FakeAncpClient();
fake.setupResponse('get-order', { status: 'Active' });
render(
<AncpProvider client={fake}>
<OrderStatus orderId="ORD-1" />
</AncpProvider>
);
Observability
ANCP is instrumented with OpenTelemetry out of the box:
| Signal | Source | Details |
|---|---|---|
| Traces | BizFirst.Ancp.Node (ActivitySource) | One span per incoming ANCP call with action, pattern, nodeId tags |
| Traces | BizFirst.Ancp.Client (ActivitySource) | One span per outgoing call with target address and duration |
| Metrics | BizFirst.Ancp (Meter) | ancp.node.calls.total, ancp.node.auth.failures, ancp.node.stream.chunks |
Register with OpenTelemetry
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing
.AddSource("BizFirst.Ancp.Node")
.AddSource("BizFirst.Ancp.Client")
.AddOtlpExporter();
})
.WithMetrics(metrics =>
{
metrics
.AddMeter("BizFirst.Ancp")
.AddOtlpExporter();
});
1. Configure JWT PublicKeyOrJwksUrl for signature validation.
2. Replace DefaultDidResolver with a real resolver for DID auth.
3. Replace InMemoryAncpTaskStore / InMemoryAncpReceiptStore with Redis-backed stores for multi-instance deployments.
4. Store SigningKeySecret in a secrets manager (Azure Key Vault, AWS Secrets Manager).
5. Set RequiredConfirmations ≥ 3 for payment gating on mainnet.