NOETIC
Framework

Connections

Give an agent live things to work with — a browser, a simulator, a desktop app, a Slack workspace, a queue — and drive them from the model, from steps, and from JSON workflows.

A connection is a named, live thing an agent works with: a browser, an iOS simulator, an Android emulator, a desktop app, a Slack workspace, a deploy queue. A harness holds any number of them. On a connection an agent can:

DoLookWaitHearCheck
Call an operationRead its stateWait for an eventA trigger starts a turnVerify a claim

Any MCP server is a connection: its tools reach the model as they are. A server that also speaks a profile — a shared, versioned vocabulary such as browser@1 or chat@1 — gets typed steps, a view of its state in the model's context, events, and verification, and works with any agent written against that profile.

Quick Example

import { AgentHarness, callModel, loop, until } from '@noetic-tools/core';
import { mcp } from '@noetic-tools/mcp';

const harness = new AgentHarness({
  name: 'browser-agent',
  params: {},
  connections: [mcp({ name: 'browser', command: 'npx', args: ['@playwright/mcp@latest'] })],
  agentGraph: loop({
    id: 'agent',
    steps: [callModel({ id: 'turn', model: 'anthropic/claude-sonnet-4.5', instructions: 'Use the browser.' })],
    until: until.noToolCalls(),
  }),
});

await harness.execute('Open example.com and follow the "More information" link');

The server's tools reach the model as browser_<tool>. Nothing else is needed.

Installation

npm install @noetic-tools/core @noetic-tools/mcp
# Profiles you write workflows against, as you need them:
npm install @noetic-tools/profile-browser @noetic-tools/profile-chat

@noetic-tools/core never imports @noetic-tools/mcp, the MCP SDK, a profile package, or @noetic-tools/verify: you pass providers, profiles, and a verifier in. Only @noetic-tools/mcp imports the MCP SDK.

Configuring Connections

connections names what backs each connection: a record keyed by the names you choose, or a list of entries each named by its provider.

import { AgentHarness, defineSource } from '@noetic-tools/core';
import { mcp } from '@noetic-tools/mcp';
import { z } from 'zod';

const deploys = defineSource({ events: { 'deploy.failed': z.object({ service: z.string() }) } });

const harness = new AgentHarness({
  name: 'ops',
  params: {},
  connections: {
    // One server, one connection.
    browser: mcp({ command: 'npx', args: ['@playwright/mcp@latest'] }),
    // A remote server, with a policy over its operations.
    slack: {
      provider: mcp({ type: 'http', url: 'https://slack-mcp.example.com/mcp' }),
      policy: { approve: ['chat.post'], deny: ['com.slack.admin.*'] },
    },
    // A pool: fresh browsers on demand.
    browsers: { provider: mcp({ command: 'npx', args: ['playwright-noetic-mcp'] }), pool: { max: 8 } },
    // An in-process source (see Triggers).
    deploys,
  },
});
Entry fieldMeaning
providerWhat backs it: mcp(…), a source, or any ConnectionProvider. A bare provider is shorthand for { provider }.
poolMakes it a pool: { max, warm?, waitMs? }. Each user gets a fresh connection (see Fresh connections).
policyallow, deny, and approve globs over operation names (see Gates).
exposeHow its operations reach the model: 'eager' (default), 'all', 'call', or 'none' (see Model tools).
lookWhat the model sees of it each turn (see Looking).

The same connections as a list, each named by mcp({ name }):

import { AgentHarness } from '@noetic-tools/core';
import { mcp } from '@noetic-tools/mcp';

const harness = new AgentHarness({
  name: 'ops',
  params: {},
  connections: [
    mcp({ name: 'browser', command: 'npx', args: ['@playwright/mcp@latest'] }),
    {
      provider: mcp({ name: 'slack', type: 'http', url: 'https://slack-mcp.example.com/mcp' }),
      policy: { approve: ['chat.post'] },
    },
  ],
});

A record key that disagrees with mcp({ name }), or a list entry with no name, is CONNECTION_INVALID.

mcp() takes ACP's McpServer shape, so an ACP server config is an argument as it stands:

ShapeFields
Remote{ name, type: 'http' | 'sse', url, headers?: [{ name, value }] } ('sse' is the old 2024-11-05 transport)
Stdio{ name, command, args?, env?: [{ name, value }] } (type: 'stdio' may be given; ACP leaves it out)
In process{ transport }, a caller-built MCP transport

Options sit beside those fields: auth, fetch (remote), cwd and spawn (stdio), webhooks when the server delivers events by webhook, profiles / extensions — the definitions the bridge checks a declaring server against — and timeoutMs, requestTimeoutMs, pollIntervalMs, clientInfo. A header or environment variable given twice is CONNECTION_INVALID. A stdio server that must run elsewhere — the machine an ACP agent runs on, a container — takes spawn, which starts the process there and returns its stdio as a { readable, writable, close } stream (Spawn from @noetic-tools/types; an ACP agent's agent.spawn is one); the bridge speaks MCP over it. policy, expose, look and pool belong to the entry, not to mcp().

Signing in to a remote server

An Authorization header signs in with a fixed token; auth signs in for you:

import type { OAuthClientProvider } from '@noetic-tools/mcp';
import { mcp } from '@noetic-tools/mcp';

declare const linearToken: string;
declare const driveOAuth: OAuthClientProvider; // your token storage and sign-in redirect

const connections = {
  linear: mcp({
    type: 'http',
    url: 'https://mcp.linear.app/mcp',
    headers: [{ name: 'Authorization', value: `Bearer ${linearToken}` }],
  }),
  drive: mcp({ type: 'http', url: 'https://drive-mcp.example.com/mcp', auth: { kind: 'oauth', provider: driveOAuth } }),
};
authWhat happens
{ kind: 'bearer', token }Every request to the server carries Authorization: Bearer <token>.
{ kind: 'oauth', provider }The MCP SDK transport reads the provider's tokens and, on a 401, runs discovery, refresh or authorization through it. OAuthClientProvider is re-exported from @noetic-tools/mcp, so you need not import the MCP SDK.

The credential (and any headers) goes to the server's origin only — never across a redirect to another origin, and configured headers never to the authorization server. Passing both auth and an Authorization header is CONNECTION_INVALID. The Authorization header and its bearer token, and every OAuth token the provider hands out or saves, is scrubbed from outputs, events, traces and connection status like a resolved secret.

Providers connect when the harness starts: on the first execute() or run(), or on an explicit await harness.start() for a harness that only reacts to triggers. Construction stays synchronous. A connection that drops is marked unavailable: its tools and steps report it, and every other connection keeps working while it reconnects. harness.getConnectionStatus(name?) reports each one's state, identity, and pool usage, and harness.close() stops triggers and releases every connection and lease, closing everything even when one part throws and then rethrowing the first failure.

Attaching at the harness, a turn, or a step

The same entry can be attached at three levels. The model sees the same tool names, every call passes the same gates, and the results, events and spans are the same wherever it is attached.

import type { ContextData } from '@noetic-tools/core';
import { AgentHarness, callModel, connectionTools, tool } from '@noetic-tools/core';
import { mcp } from '@noetic-tools/mcp';
import { z } from 'zod';

declare const token: string;
const LINEAR_MCP = 'https://mcp.linear.app/mcp';
const search = tool({
  name: 'search',
  description: 'Searches the docs.',
  input: z.object({ query: z.string() }),
  output: z.string(),
  execute: async () => 'no docs',
});

// For one step: offered only by the steps that list it.
const triage = callModel<ContextData, string, string>({
  id: 'triage',
  model: 'anthropic/claude-sonnet-4.5',
  tools: [mcp({ name: 'linear', type: 'http', url: LINEAR_MCP, headers: [{ name: 'Authorization', value: `Bearer ${token}` }] })],
});

// For the harness's life.
const harness = new AgentHarness({
  name: 'triage',
  params: {},
  agentGraph: triage,
  connections: [mcp({ name: 'linear', type: 'http', url: LINEAR_MCP, headers: [{ name: 'Authorization', value: `Bearer ${token}` }] })],
});

// Part of a harness or turn connection, picked by name.
const reader = callModel({
  id: 'read',
  model: 'anthropic/claude-sonnet-4.5',
  tools: [connectionTools('linear', { ops: ['get_*'] }), search],
});

// For one turn: connected when it starts, released when it ends.
const linear = mcp({ name: 'linear', type: 'http', url: LINEAR_MCP, headers: [{ name: 'Authorization', value: `Bearer ${token}` }] });
await harness.execute('Triage ENG-1', { connections: [linear] });
LevelConnectsReleased
Harness connectionsat harness.start()at harness.close()
execute(input, { connections })when the turn starts, before its tool set is builtwhen the turn ends
A callModel or acpAgent step's toolswith the turn's entrieswith them (a kept ACP session holds them until it closes)
  • A pick by name. connectionTools('linear', { ops }) picks a connection the harness or the turn attaches. The name is checked when the turn's tool set is built: no connection with that name is CONNECTION_UNKNOWN, and a name that is not a letter followed by letters, digits, _ or - is CONNECTION_INVALID at once.
  • Identity is the name. An entry whose name the harness, the turn or another step already attaches is that connection when it has the same provider and options. Otherwise it is CONNECTION_NAME_TAKEN.
  • Function-form tools cannot be walked before the turn, so an entry they return must already be attached by the harness or the turn; any other is CONNECTION_INVALID.
  • Pools stay at the harness: a turn or step entry with pool is CONNECTION_INVALID.
  • toolChoice: { tool } naming a connection tool is checked when the turn's tool set is built, and fails there with UNKNOWN_TOOL_CHOICE.
  • Spawned children see their root turn's connections.
  • ACP agents get a step entry's tools through noetic's tool bridge, by the same names and through the same gates as a model; see Giving the agent your tools. A connection tool's idempotency key is <execution>:tool:<callId> on both paths.
  • Handles and triggers. step.call, step.wait, step.verify and connection() handles reach harness and turn connections by name; a step entry is reached only through its model tools. Triggers reach harness connections only.

Harness environment

Two environment entries serve connections:

FieldMeaning
environment.secretsA SecretResolver that turns a secret('name') into its value immediately before a call is sent.
environment.artifactsThe ArtifactStore blobs (screenshots, recordings, verification runs) are stored in. Defaults to an in-memory store; use createFileArtifactStore() from @noetic-tools/platform-node for disk. harness.artifacts.listRun(executionId) lists a run's artifacts.

Model Tools

How a connection's operations reach the model is its expose setting:

exposeTools
'eager' (default)The operations its profile marks eager, plus two tools for the rest: <name>_ops lists them and <name>_call runs one.
'all'Every operation as its own tool.
'call'Only <name>_ops and <name>_call (which also reads, { read }). Nothing is rendered into the context; the model lists what it needs.
'none'None; the connection is for steps and triggers only.

A server with no profile has no eager set, so its tools are exposed as they are. Every call through any of these tools passes the same gates as a step.

When two connections offer the same profile operation — ui.tap on an ios and an android device — the model gets one ui_tap tool with a target argument naming the connection, instead of two near-identical tools. Two or more 'call' connections likewise share one ops and one call tool, each taking target.

A model step with no tools is offered every tool of the turn except those of connections only some step lists. A step that lists a connection entry is offered its tools; connectionTools(nameOrHandleOrEntry, { ops }) returns a ConnectionPick that narrows them. A pick cuts the turn's tools to what it names and keeps their names — <name>_<op>, <name>_ops / <name>_call, merged ui_tap with target. ops globs match operations and reads alike. A merged tool reaches only the connections the step picks: its target enum is cut to them and a call to any other target is refused, and <name>_call runs only the picked operations and 'ask' reads:

import { callModel, connection, connectionTools } from '@noetic-tools/core';
import { device } from '@noetic-tools/profile-device';

const phone = connection('phone', device);

const turn = callModel({
  id: 'turn',
  model: 'anthropic/claude-sonnet-4.5',
  instructions: 'Find the settings screen.',
  tools: [connectionTools(phone, { ops: ['ui.*'] })],
});

Looking

A profile names the state worth showing the model — a page's URL and title, a device's foreground app, a thread's recent messages — and each connection gets a context layer that renders it. look sets when each piece is read:

const look = { 'browser.page.summary': 'turn', 'ui.tree': 'effect', 'screen.current': 'ask' } as const;
ModeRead
'turn'Every turn. For cheap summaries.
'effect'After an operation that changed the connection.
'ask'Only when the model asks, through <name>_call({ read }).

Profiles choose sensible defaults: a short summary every turn, a full tree after changes, screenshots only on request. Repeated reads render as diffs, and each read's token cost is recorded in traces.

Steps

import { connection, secret, step } from '@noetic-tools/core';
import { browser } from '@noetic-tools/profile-browser';

const app = connection('app', browser, { fresh: 'path', setup: { url: 'http://localhost:5173/login' } });

const login = [
  step.call({ id: 'email', connection: app, op: 'ui.fill', args: { element: 'textbox "Email"', text: 'qa@example.com' } }),
  step.call({ id: 'password', connection: app, op: 'ui.fill', args: { element: 'textbox "Password"', text: secret('app.password') } }),
  step.call({ id: 'sign-in', connection: app, op: 'ui.tap', args: { element: 'button "Sign in"' } }),
];
  • connection(name, profile?, options?) declares a connection a workflow needs and returns a handle. With a profile, op, args, and outputs are typed by it, and the profile's range defaults to the installed package's version (^1.2.0); browser.range('^1.1') overrides it. The handle binds to the harness connection of the same name, or to options.from. options.extensions lists the extensions it requires.
  • step.call({ id, connection, op, args }) runs one operation and returns its result. Operations that run long upstream (an app install, a test run) are waited for automatically. connection may also be a harness connection name, in which case the call is untyped.
  • step.wait({ id, connection, event, filter?, around?, timeoutMs }) waits for an event. With around, it starts listening, runs around, then waits — so the event an action causes cannot slip past. Its output is { event, around }. If no matching event arrives within timeoutMs, the step fails with step_failed.
  • step.verify checks claims about what a connection shows.
  • args may be a value or a function of the step's input; around and act take a step or an array of steps. As in the other places that accept an array of steps, such as harness.run, an array runs its steps in sequence, so there is no step.sequence.
import { connection, refId, step, type Ref } from '@noetic-tools/core';
import { chat, ChatMessageSchema } from '@noetic-tools/profile-chat';

const team = connection('team', chat);
declare const thread: Ref<'chat.thread'>;

const approval = step.wait({
  id: 'approval',
  connection: team,
  event: 'chat.message.created',
  filter: (event) => {
    const message = ChatMessageSchema.parse(event.payload);
    return refId(message.thread) === refId(thread) && /approve/i.test(message.text);
  },
  around: step.call({ id: 'ask', connection: team, op: 'chat.reply', args: { thread, text: 'Approve?' } }),
  timeoutMs: 18e5,
});

Fresh connections

A handle with fresh gets its own connection from a pool, and gives it back when the scope ends:

freshOne connection per
'run'Run: every step in the run shares it.
'path'Parallel path: each inParallel path gets its own.

from names the pool (default: the handle's name); setup is passed to the pool's provider — a start URL, a stored session, a viewport, a device model — and is validated against the profile's setup schema. A pool that is full queues the request until a connection is free or waitMs passes.

import { connection, inParallel, type ContextData, type Step } from '@noetic-tools/core';
import { browser } from '@noetic-tools/profile-browser';

declare const previewUrl: string;
declare function checkoutFlow(app: typeof shop): Step<ContextData, unknown, unknown>;
declare function signupFlow(app: typeof shop): Step<ContextData, unknown, unknown>;

const shop = connection('app', browser, { from: 'browsers', fresh: 'path', setup: { url: previewUrl } });

// Two fresh browsers, one per path.
const suite = inParallel({ id: 'suite', mode: 'all', paths: () => [checkoutFlow(shop), signupFlow(shop)], merge: (results) => results });

A lease is held by one run or one parallel path and never shared. The harness renews it every 15 s; a lease not renewed for 2 minutes is reclaimed, so a crashed process frees its browsers, and a process that resumes within that time keeps the same lease. Setup fields a profile marks host-only — the command or path of an app — come only from the pool's own setup, never from a workflow.

Values

ValueWhat it is
LocatorA string where an operation expects an element: 'button "Pay"' (role, then quoted name). The server resolves it to exactly one element or refuses with ambiguous; it never guesses.
RefAn id a connection handed out — a thread, a message, an installed app. Pass it back as you got it; the harness routes the call to the connection that issued it. In TypeScript a Ref holds connection, kind, and id (read the id with refId(ref)); in JSON it is { "$ref": … }, read with $refId(x).
BlobBinary content — a screenshot, a recording, a download — stored once in the artifact store and passed around as a small { "$blob": { uri, mimeType, bytes, sha256 } } reference.
Secretsecret('app.password'): resolved only when the call is sent, never stored, logged, or shown to the model, and allowed only in fields a profile marks sensitive.

Refs are sealed: an HMAC over the ref, the issuing thread, the pool lease for a leased connection and, for volatile refs (elements, windows), the connection's identity. A ref cannot be forged, used from another thread (spawned children and parallel paths share their parent's), used on another lease of the same pool, or reused after the thing it pointed into was replaced. Signing keys live in the harness StorageAdapter, so refs survive a restart.

Blobs a profile marks sensitive — a stored session, blob([], { sensitive: true }) — are stored with sensitive: true and never rendered to the model: connection views and model tool results show [sensitive blob withheld] in their place.

Before any output, event, read, or text artifact is stored, traced, or shown to the model, the harness replaces every resolved secret value in it — and its URL-encoded and base64 forms — with "<redacted>". Pixels cannot be scrubbed; profiles require servers to mask sensitive fields on screen.

Triggers

A trigger starts or joins a turn when a connection reports an event:

import { AgentHarness, cron } from '@noetic-tools/core';
import { mcp } from '@noetic-tools/mcp';
import { ChatMessageSchema } from '@noetic-tools/profile-chat';

declare const SLACK_MCP: string;
declare function isBot(author: { name?: string }): boolean;

const harness = new AgentHarness({
  name: 'helper',
  params: {},
  connections: {
    slack: mcp({ type: 'http', url: SLACK_MCP }),
    morning: cron('0 8 * * *', { timezone: 'America/New_York' }),
  },
  triggers: [
    {
      id: 'mentions',
      connection: 'slack',
      event: 'chat.message.created',
      arguments: { mentionsAgent: true }, // filter on the server
      filter: (e) => !isBot(ChatMessageSchema.parse(e.payload).author), // filter here
      input: (e) => ChatMessageSchema.parse(e.payload).text,
    },
    { id: 'daily', connection: 'morning', event: 'tick', input: () => 'Write the daily summary' },
  ],
});

await harness.start();

Each matching event starts a turn on its thread — thread(event), defaulting to the profile's natural thread for that event (a chat thread) or the trigger's id — with input(event) as the message. The event itself is attached to the turn as a noetic:connection_event item, so steps can read it.

A trigger delivers each event once, even across crashes: matching events are written to a durable inbox together with the advanced cursor, delivered as turns with message id <connection>:<trigger>:<event id>, and recorded as done when their turn completes.

Sources

Sources are event streams you write in code — a queue consumer, a file watcher, a webhook from your own app — registered as connections:

import { defineSource } from '@noetic-tools/core';
import { z } from 'zod';

const DeployFailedSchema = z.object({ service: z.string(), reason: z.string() });
declare const queue: {
  consume(opts: { after: string | null; signal: AbortSignal }): AsyncIterable<{ id: string; body: z.infer<typeof DeployFailedSchema>; offset: string }>;
};

const deploys = defineSource({
  events: { 'deploy.failed': DeployFailedSchema },
  async start({ emit, cursor, signal }) {
    for await (const msg of queue.consume({ after: cursor, signal })) {
      await emit({ type: 'deploy.failed', id: msg.id, payload: msg.body, cursor: msg.offset });
    }
  },
});

// Or push from host code:
await deploys.emit({ type: 'deploy.failed', id: 'run-42', payload: { service: 'api', reason: 'OOM' } });

cron(schedule, { timezone }) is a built-in source with one event, tick, delivered once per scheduled time even after downtime.

A chat bot that answers every message — streaming replies, tool cards, history — belongs on @noetic-tools/chat-sdk. Chat connections are for agents that start conversations or wait on people: post a question, wait for the answer.

JSON Workflows

A JSON workflow lists the connections it needs, each with a version range:

{
  "version": 1,
  "connections": {
    "app":     { "profile": "browser@^1.2", "from": "browsers", "fresh": "path", "setup": { "url": "http://localhost:5173/login" } },
    "phone":   { "profile": "device@^1", "extensions": { "com.acme.ios-permissions": "^1.0" } },
    "deploys": { "events": ["deploy.failed"] }
  },
  "root": { "kind": "sequence", "id": "main", "steps": [
    { "kind": "call", "id": "email", "connection": "app", "op": "ui.fill",
      "args": { "element": "textbox \"Email\"", "text": "qa@example.com" } },
    { "kind": "call", "id": "password", "connection": "app", "op": "ui.fill",
      "args": { "element": "textbox \"Password\"", "text": { "$secret": "app.password" } } },
    { "kind": "call", "id": "sign-in", "connection": "app", "op": "ui.tap",
      "args": { "element": "button \"Sign in\"" } }
  ] }
}
  • The manifest. Each entry names a profile with a range, extensions with ranges, or for a source the events it needs; plus from, fresh, and setup for pooled connections. An entry may instead bring its own server: "linear": { "mcp": { "name": "linear", "type": "http", "url": "…", "headers": [{ "name": "Authorization", "value": { "$secret": "linear.token" } }] } } is attached when the document runs, and every node reaches it by name. Its key must equal mcp.name, it takes no from, fresh or setup, and the host's mcp allowlist grants it. Every operation and event a node uses must come from what its entry declares; anything else fails validation. The optional operations a document uses are worked out from its nodes, so they are never listed twice.
  • Nodes. call, wait, and verify, shaped like their TypeScript builders. Inside around, act, or any array of steps, an operation can be written by name: { "ui.tap": { "element": "button \"Pay\"" } } is a call on the enclosing connection (the verify subject, or the document's only connection).
  • Values. { "$expr": "…" } is an expression, evaluated against the node's input with $steps holding the outputs of earlier nodes by id; { "$literal": … } passes an object through untouched; { "$secret": "name" } is a secret; refs and blobs appear as they were returned. $refId(x) reads a ref's id inside an expression.
  • Fan-out. inParallel with over a list of connection names and "connection": { "$expr": "$item" } on its nodes runs the same steps on each, every name checked against the manifest.
  • Model tools. A callModel or acp-agent node's tools take { "connection": "<name>", "ops"?: [...] } for a harness or turn connection, and { "mcp": { "name", "type", "url", "headers"? } } (or { "mcp": { "name", "command", "args"?, "env"? } }) for a server the step attaches — the same tools, under the same names, as connectionTools(…) and tools: [mcp(…)]. Header and environment values are $secrets only; the URL or exact command, the secrets and the policy come from the host's mcp allowlist. See Connection tools.

What a document may use

A document's manifest says what it needs; the host decides what it may have, through the connections allowlist of dynamicWorkflow and parseAndRunWorkflow:

import { dynamicWorkflow } from '@noetic-tools/core';

const planner = dynamicWorkflow({
  tools: [],
  connections: {
    browsers: {
      ops: ['ui.*', 'browser.*'],
      setup: { url: ['http://localhost:*'] },
      secrets: { 'app.password': 'ui.fill.text' },
    },
    slack: { ops: ['chat.reply'], events: ['chat.message.created'] },
  },
});

Each entry grants operation and event globs, the setup values a document may choose (patterns per field), and the secrets it may use, each bound to one operation field. A manifest asking for more fails validation with CONNECTION_NOT_ALLOWED. dynamicWorkflow shows the model only the allowed connections and operations, with their argument schemas, and fills in the manifest's version ranges itself.

The same entries bound a document's { "connection" } tool entries: the name must be granted (a harness or a turn connection), each ops glob must lie inside a granted one, and a pick without ops takes exactly the granted operations. A callModel or acp-agent node with no tools is offered only the granted connections and the MCP servers the document attaches.

Without connections a document is granted nothing, as with acp and mcp: no option is connections: {}, so a manifest entry, a { "connection" } pick or a call, wait or verify node naming a harness connection fails with CONNECTION_NOT_ALLOWED, and a callModel or acp-agent node with no tools is offered no connection. A host that trusts a document passes the grants it wants it to have.

Gates

Every connection call — from the model, a step, or a document — passes the same gates, in order:

  1. The operation exists on the connection, and for steps and documents is declared by the manifest or handle.
  2. Its arguments match the operation's schema. Nothing is defaulted on the caller's behalf.
  3. Its refs were issued by this harness to this thread; the call is routed to the connection that issued them.
  4. The connection's policy. Operations a profile marks guarded, and targets a server refuses as guarded, need approval unless the policy allows them.
  5. beforeToolCall layers and the harness approver, as for any tool.
  6. Secrets are filled in, and the call is sent.

A refusal fails a step with steering_denied and returns a tool error to the model. Author-written steps are gated like everything else.

Errors

Setup and validation raise a NoeticConfigError:

CodeWhen
CONNECTION_UNKNOWNA handle, node, trigger, allowlist or connectionTools('<name>') pick names a connection the harness (or, for a pick, the turn) does not have.
CONNECTION_UNSATISFIEDA connection does not offer what is required. missing says which: a profile or extension version, an operation, an event, or a sense.
CONNECTION_NOT_ALLOWEDA document asks for more than its allowlist grants, or sets a setup field reserved to the host.
CONNECTION_INVALIDA manifest entry, handle or harness entry is malformed: an unnamed list entry, a record key that disagrees with mcp({ name }), a turn or step entry with pool, a function-form tools returning a connection nothing attached, an mcp() config that is not ACP's McpServer shape, names a header or environment variable twice, or gives both auth and an Authorization header, or a document { "mcp" } connection whose key disagrees with its name.
CONNECTION_NAME_TAKENAn entry names a connection its scope or an outer one already attaches with a different provider or options.
CONNECTION_TOOL_COLLISIONTwo operations would reach the model under one tool name.

At run time, a failed call raises a NoeticError of one of these kinds:

KindPayloadWhen
connection_unavailableconnection, reasonDisconnected, its pool stayed full past waitMs, or an event stream it was waiting on ended.
connection_failedconnection, op, errorThe server refused or failed the operation; error.code is one of the profile error codes.
connection_ref_invalidconnection, reasonA ref was not issued here, belongs to another connection than the call's target, or is stale.
connection_secret_refusedconnection, opA secret was used outside a sensitive field or by the model.
connection_input_requiredconnection, requestA long-running operation asked a question nobody could answer.
connection_resume_refusedconnection, reasonA resumed run's connection was replaced and cannot safely be driven again (see Durability).

Observability

Connections emit framework events on getFullStream():

EventMeaning
connection_changedA connection's state, identity, or lease changed.
connection_eventAn event was received.
connection_taskProgress of a long operation.
stream_changedAn event stream had a gap or ended.

Durability

Idempotency keys. Every call carries a key derived from the run and the step's ledger key (or the turn and tool call id), as _meta["tools.noetic/idempotency-key"]. Operations a profile marks once — lasting effects such as posting a message or installing an app — return their original outcome for a repeated key instead of acting again.

Pending entries. A call or wait writes a pending ledger entry before it reaches a server — its key, the connection's identity, and once known the task, the lease, the start cursor, and the deadline. On resume against the same identity, a step with a pending entry and no result picks up where it was: re-sending with the same key, waiting on the same task, or listening again from the recorded cursor until the recorded deadline.

A replaced connection. If a connection's identity changed — the browser restarted, the lease expired, the simulator was reset — results recorded against it describe a world that is gone. The connection's determinism decides:

DeterminismResume
replayPrefix: 'free'Discard the ledger from the first step that used the connection and run live from there.
replayPrefix: 'costly', with resetReset the connection, then the same.
replayPrefix: 'unsafe'Stop with connection_resume_refused until harness.restore(id, { rerun: ['app'] }).

Everything after the discarded point runs live too, model rounds included, because its inputs came from the old connection. Calls marked once still carry their original keys. An epoch change alone (a hot reload) keeps refs and results. A connection that declares no determinism is treated as 'unsafe', and so is a 'costly' one that cannot reset.

Each ledger entry records the identity of every connection its step used, so the harness checks them before a resumed run replays anything:

import type { AgentHarness, ContextData, Step } from '@noetic-tools/core';

declare const harness: AgentHarness;
declare const graph: Step<ContextData, string, string>;
declare const executionId: string;
declare const input: string;

const ctx = await harness.restore(executionId, { rerun: ['app'] });
if (ctx) await harness.run(graph, input, ctx);

rerun belongs to the context that restore returns: it holds for that context's run and ends when the run does, even one that fails before it executes. A later restore(executionId) without rerun is refused again.

A failed run keeps its pool leases, no longer renewed: a resume within the reclaim window takes them back; otherwise they are reclaimed. A fresh: 'path' lease goes back to its pool as soon as its inParallel path ends.

Writing a Provider

Steps, tools, and documents never hold a provider's Connection. They resolve connections by name through the harness's registry when they run, which wraps each one with the gates, refs, blobs, secrets, identity tracking, reconnection, and pool leases. A provider implements this contract:

interface ConnectionProvider {
  connect(opts: ConnectOptions): Promise<ReadonlyArray<Connection>>;
  /** Present on providers that can back a pool. */
  provision?(setup: Record<string, unknown>, opts: ConnectOptions): Promise<Connection>;
}

interface Connection {
  readonly id: string;
  readonly manifest: ConnectionManifest; // profiles, extensions, operations, resources, events
  readonly identity: ConnectionIdentity; // { surface, instance, epoch? }
  call(op: string, args: unknown, opts: CallOptions): Promise<CallResult>;
  read(resource: string, opts: { signal: AbortSignal }): Promise<unknown>;
  listen(req: ListenRequest): EventStream;
  readonly tasks: ConnectionTasks;
  reset?(opts: { signal: AbortSignal }): Promise<void>;
  onChange(listener: (change: 'manifest' | 'identity') => void): () => void;
  readonly closed: Promise<{ reason: string }>;
  release(): Promise<void>;
}

A connection's identity instance changes whenever the thing it drives is replaced; epoch moves on code edits.

ConnectOptions.redact(value) registers a credential the provider holds (a bearer token, an OAuth token); the harness then scrubs it everywhere it scrubs resolved secrets. mcp() calls it for its auth credentials, every configured header value, the URL's userinfo and query values, and every stdio env value — skipping values under 8 characters and absolute paths, which are flags and locations rather than credentials.

Each operation in a manifest may carry annotations — the server's hints title, readOnlyHint, destructiveHint, idempotentHint and openWorldHint. mcp() copies them from the MCP tool's annotations. A host can read them from connection.manifest.operations to choose a default policy (allow read-only operations, approve destructive ones); the gates never trust them in place of a policy or a profile's guarded.

createInMemoryConnection() implements the whole contract as scripted operations, events, and tasks for tests; given provision, it also backs pools:

import { AgentHarness, createInMemoryConnection, step } from '@noetic-tools/core';

const shop = createInMemoryConnection({
  surface: 'web',
  events: { 'browser.request': { sense: 'net', replay: 'live' } },
  operations: {
    'ui.tap': {
      effects: true,
      run: (_args, call) => {
        call.connection.emit('browser.request', { summary: 'POST /api/orders 201' });
        return { receipt: { ok: true } };
      },
    },
  },
});

const harness = new AgentHarness({ name: 'test', params: {}, connections: { app: shop } });
await harness.run(step.call({ id: 'pay', connection: 'app', op: 'ui.tap' }), undefined, harness.createContext());
console.log(shop.connection.calls); // [{ op: 'ui.tap', … }]

mcp() implements the contract over MCP: tools for operations, resources for reads, the Tasks extension (SEP-2663) for long operations, and the Events extension (SEP-3415) for events. It reads a server's profile declaration during capability discovery and checks the server's tools against it; a server whose tools contradict its declared profile is marked unavailable. A server with no declaration is a connection with no profile: its tools pass through, and it has no reads, events, or verification.

mcp() receives events by push, webhook, or poll, preferring them in that order; callers never see the difference. For webhook delivery, pass a receiver — createWebhookReceiver() from @noetic-tools/platform-node — as mcp(server, { webhooks: { receiver } }). Deliveries are verified (Standard Webhooks signature, five-minute timestamp window, duplicate ids dropped).

Agent plugins turn each plugin MCP server into an mcp() connection entry with expose: 'call' (pluginConnections()), which you attach to the harness or a turn, so a plugin's tools and a configured server's tools share one registry, one naming rule, and one set of gates.

On this page