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.

.NET 9
C# 13
TypeScript 5
React 18
Spec v1.0.2

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

PatternHTTPUse Case
request-replyPOST → 200Synchronous query/command
fire-and-forgetPOST → 202Event / log / one-way notification
streamingPOST → SSEAI token streaming, real-time reports
task-startPOST → 202 + taskIdLong-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

RolePackageResponsibility
Node (receiver)BizFirst.Ancp.NodeHosts actions, validates auth, dispatches to handlers
Client (caller)BizFirst.Ancp.ClientBuilds 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
    };
}
Tip

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>();
Security

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

ModeHeaderPriorityUse Case
JWTAuthorization: Bearer <token>1st (highest)Human users, external IdPs (Auth0, Keycloak)
API KeyX-Ancp-Api-Key: bfk_...2ndService-to-service, CI/CD pipelines
DID ProofX-Ancp-Did-Proof: <jwt>3rdCross-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:

ActionPatternDescription
ancp.pingrequest-replyHealth check — returns nodeId, uptime, version
ancp.capabilitiesrequest-replyLists all registered actions with patterns and pricing
ancp.negotiaterequest-replyPre-call capability negotiation — checks if actions exist
ancp.payment-inforequest-replyReturns node's EVM address, accepted tokens, price list
ancp.payrequest-replySubmit a payment txHash and receive a signed receipt
ancp.statusrequest-replyOperational 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:

SignalSourceDetails
TracesBizFirst.Ancp.Node (ActivitySource)One span per incoming ANCP call with action, pattern, nodeId tags
TracesBizFirst.Ancp.Client (ActivitySource)One span per outgoing call with target address and duration
MetricsBizFirst.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();
    });
Production checklist

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.