NOETIC
Framework

Connection Profiles

Shared, versioned vocabularies — browser, app, device, chat — so any server that speaks one works with any agent written against it.

MCP standardizes how to call a tool. It does not say which tools a browser server offers or what a chat event looks like, so an agent written against one server breaks on the next. A profile fixes that vocabulary for one kind of thing — its operations, the state worth reading, its events — so any server that speaks it works with any agent written against it.

A profile is a floor, not a ceiling. A server implements the required parts and may offer anything else as extensions: versioned, namespaced additions an agent can require by name.

Using a Profile

Import it and pass it to connection(). That is all a workflow author does with a profile: operations, arguments, and results become typed, documents are checked against it, and verification knows what each part can prove.

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

const app = connection('app', browser);

// `op` autocompletes from browser@1 and ui@1; `args` and the output are typed.
const open = step.call({ id: 'open', connection: app, op: 'browser.navigate', args: { url: 'https://example.com' } });

The harness hands the profiles its graph's handles name to each provider when it connects, so mcp() checks the server against them and takes their flags (guarded, once, effects, eager), senses and argument schemas — a sensitive field accepts secret() — without being given the profile again.

Each profile is its own package, @noetic-tools/profile-<name>, depending only on @noetic-tools/types and the profile it extends:

PackageExportsExtends
@noetic-tools/profile-uiui—
@noetic-tools/profile-screenscreen—
@noetic-tools/profile-browserbrowserui@^1
@noetic-tools/profile-appappui@^1
@noetic-tools/profile-devicedeviceui@^1
@noetic-tools/profile-chatchat—

A handle requires the installed profile's version by default (^1.0.0); browser.range('^1.1') requires another range.

Defining a Profile

import { defineProfile, receipt, ref } from '@noetic-tools/types';
import { z } from 'zod';

const MessageSchema = z.object({ thread: ref('chat.thread'), text: z.string() });

export const chat = defineProfile({
  name: 'chat',
  version: '1.0.0',
  operations: {
    'chat.reply': {
      description: 'Reply in a thread.',
      input: z.object({ thread: ref('chat.thread'), text: z.string() }),
      output: z.object({ message: ref('chat.message'), receipt: receipt() }),
      eager: true,
      effects: true,
      once: true,
    },
  },
  reads: {
    'chat.thread.recent': { schema: z.object({ messages: z.array(MessageSchema) }), sense: 'state', look: 'effect' },
  },
  events: {
    'chat.message.created': {
      payload: MessageSchema,
      arguments: z.object({ thread: ref('chat.thread').optional(), mentionsAgent: z.boolean().optional() }),
      sense: 'state',
      replay: 'durable',
      thread: 'thread',
    },
  },
});

A profile is data: one definition types steps, validates documents, generates the JSON Schema, checks servers, and drives verification.

PartFields
name, versionOne lowercase label; semver. The major is part of its identity: chat@1.
extendsParent profiles with ranges: { ui: ui.range('^1') } inherits the parent's parts unchanged ({ ui: '^1' } declares the requirement without inheriting). Requiring the child requires them.
operations{ description, input, output, required?, eager?, effects?, once?, guarded?, sense? }
readsState worth reading: { schema, sense, look? }
events{ payload, arguments?, sense, replay, thread?, required? }
setupWhat a pool accepts to create a fresh connection: { schema, hostOnly? }.
determinismHow connections of this kind may be driven again on resume (see Writing a server).

Every part is required unless marked required: false. The flags:

FlagMeaning
eagerShown to the model as its own tool by default. Keep this set small; each eager operation costs tokens every turn.
effectsChanges something. The output includes a receipt(): { actionId, cursor } — the action was sent and when, never whether it worked.
onceA lasting effect (a post, an install). Servers must honor idempotency keys for it.
guardedDestructive. Needs approval unless policy allows it.
senseThe sense its output, read, or event is evidence on (see Senses).
lookWhen a read is shown to the model: 'turn', 'effect', or 'ask' (see Looking).
replayWhether a listener can resume an event stream: 'durable' (across server restarts), 'live' (for the connection's current life), or 'none'.
threadThe payload field that names the event's natural thread, used by triggers.

Field helpers, all from @noetic-tools/types:

HelperMeaning
ref(kind, { volatile?, public? })An id this connection hands out. volatile refs (elements, windows) die with the screen or the connection's current life; public refs (a channel id) can be typed by a person.
target(kind)A ref of that kind or a locator string ('button "Pay"'), resolved by the server to exactly one thing.
sensitive(schema)A field a secret may fill; redacted wherever it is stored or reported.
blob(mimeTypes, { sensitive? })Binary content, carried as a blob reference. A sensitive blob (a stored session) is never rendered to the model.
receipt()The receipt every effects operation returns.

Senses

A sense is where evidence comes from: what a connection can perceive about the thing it drives. Senses are the evidence channels of the Open Verification Protocol (OVP §3), renamed in Noetic so they do not collide with Channel<T> or a chat channel; verification translates them back at the OVP boundary.

SenseHoldsSummary formatMatcher
uiWhat is on screenrole "name" — button "Pay"A locator: button "Pay", heading
netRequests and responsesMETHOD /path STATUS — POST /api/orders 201 (query omitted; full origin for cross-origin)Any of method, path, status: POST /api/orders 201, GET /api/*, 5xx
logErrors and messageslevel message — error Uncaught TypeError: x is undefinedLevel, optionally a quoted substring: error, error "TypeError"
routeWhere the user isthe path — /checkout/doneA path, * and ** globs allowed
stateApplication statestore.path — cart.items (value carried separately)A path, optionally = value
signalEvents the application announcesthe signal name — order.placedA name
storageValues that outlive a screenkind key — local sessionKind and key
timeWhat settled and what is in flightsettled / in-flight NAMEA word
visualPixelscapture—

Servers produce summaries in exactly these formats, so a matcher written against the table means the same thing on every server. A matcher with no wildcard lowers to OVP's exact summary match; one with a wildcard or a status class is evaluated by Noetic as an implementation predicate and still names its sense. Senses outside the table are prefixed x- and documented by their profile.

Extensions

import { defineExtension, receipt, ref } from '@noetic-tools/types';
import { z } from 'zod';

const BlockSchema = z.object({ type: z.string() }).loose();

export const slackBlocks = defineExtension({
  id: 'com.slack.blocks',
  version: '3.0.0',
  requires: { chat: '^1' },
  operations: {
    'com.slack.blocks.post': {
      input: z.object({ channel: ref('chat.channel'), blocks: z.array(BlockSchema) }),
      output: z.object({ message: ref('chat.message'), receipt: receipt() }),
      effects: true,
      once: true,
    },
  },
  extend: {
    'chat.post': { input: z.object({ unfurlLinks: z.boolean().optional() }) },
    'chat.message.created': { payload: z.object({ blocks: z.array(BlockSchema).optional() }) },
  },
});
  • Ids are a reverse domain the vendor controls plus a name, at least three labels, so they cannot clash with a profile. Everything an extension adds is named under its id.
  • extend adds optional fields to profile operations and events, always under ext["<extension id>"], so extensions never collide with each other or with a later profile version, and code using only the profile is unaffected.
  • An extension can never change a profile schema or make an optional part required. Extensions version independently, by the rules below.

A handle requires extensions with connection('team', chat, { extensions: [slackBlocks.range('^3')] }); a JSON manifest with "extensions": { "com.slack.blocks": "^3" }.

Versioning

ChangeVersion
A fix to a descriptionpatch
A new optional operation, read, event, field, or argumentminor
Anything removed, renamed, narrowed, or made required; a weaker replay; a changed sense or determinismmajor

A new major is a new identity. A connection implements one major of a profile.

Catalog v1

★ eager · ○ optional. Every operation with effects returns a receipt besides what is shown. Timeouts are milliseconds.

ui@1 — acting on what an interface is

The base of browser, app, and device. Operations take an optional within ref (a tab, a window) to scope them; element fields are target()s.

OperationInput → Output
ui.find{ query, within? } → { elements: [{ element: ref, role, name, text? }] } (sense ui)★
ui.describe{ element? | within?, full? } → the accessibility tree with refs (sense ui)★
ui.tap{ element, count? } (effects)★
ui.fill{ element, text: sensitive } — replaces the value (effects)★
ui.type{ element, text: sensitive } — appends (effects)
ui.key{ key, modifiers?, element? } (effects)
ui.scroll{ element? | within?, direction, amount? } (effects)
ui.select · ui.check · ui.hover · ui.drag · ui.uploadthe usual arguments (effects)○
ui.settle{ until: 'quiet' | 'event', event?, quietMs?, after?: receipt, timeoutMs } → { closedBy: 'quiescence' | 'signal' | 'budget-exhausted', unmeasurable? } (sense time)★

closedBy values are OVP close conditions. When a settle cannot measure quiet — a hidden tab never paints — it waits quietMs with no events, returns closedBy: 'quiescence' with unmeasurable: true, and never reports a timeout for that reason. Reads: ui.summary (focused element and counts; look: 'turn'), ui.tree ('effect'). Events ○: ui.changed. Element refs are volatile.

screen@1 — pixels

For surfaces with no structure (games, canvases, remote desktops), and as an optional companion to ui@1.

OperationInput → Output
screen.capture{ within?, region? } → { frame: ref, image: blob, width, height } (sense visual)
screen.tap{ frame, x, y, count? } (effects)
screen.drag{ frame, path: [{ x, y, dtMs? }] } (effects)○

Coordinates are pixels of the capture whose frame they carry; a server refuses a frame that no longer matches the screen with stale_reference instead of tapping something that moved. Reads: screen.current (look: 'ask').

browser@1 (extends ui@^1)

OperationInput → Output
browser.navigate{ url, tab? } → { url, title } (effects)★
browser.back · forward · reload{ tab? } → { url } (effects)
browser.tabs{} → { tabs: [{ tab: ref, url, title, active }] }
browser.tab.open · close · select{ url? | tab } → { tab: ref } (effects)
browser.dialog.respond{ dialog: ref, accept, text? } (effects)
browser.requests{ since?, actionId?, filter? } → { requests: [{ summary, method, url, status, timing }] } (sense net)○
browser.console{ since?, actionId?, level? } → { messages } (sense log)○
browser.state{ path? } → { value } (sense state; from an SDK inside the app)○
browser.evaluate{ expression, tab? } → { value } (guarded)○

Reads: browser.page.summary (url, title, route; 'turn'), browser.page ('effect'). Events, all replay: 'live' with an actionId argument: browser.page.loaded (sense route), browser.dialog.opened (sense ui), and ○ browser.request (net), browser.console.message (log), browser.signal (signal), browser.download (x-artifact, carries a blob). Setup: { url?, session?: blob, viewport?, locale?, timezone? }.

A browser server reaches OVP's effect level with net and log, in-realm adding state and signal, and surface adding ui.

app@1 (extends ui@^1) — desktop applications

OperationInput → Output
app.launch{} → { app: ref } — the app this connection was set up for (effects)○ ★
app.attach{ match? } → { app: ref } — an app already running○
app.windows{ app? } → { windows: [{ window: ref, title, focused }] }★
app.window.focus{ window } (effects)
app.terminate{ app } (effects)
app.state{ path? } → { value } (sense state)○

A server offers app.launch, app.attach, or both. Reads: app.summary ('turn'). Events: app.exited (sense x-exit: the app's own exit code) and app.terminated (sense x-termination: ended by the operating system or a supervisor) — separate senses because one is the app describing itself and the other is not; ○ app.signal (signal). Window refs are volatile. Setup: { path | command, args?, env? }, with path, command, and env reserved to the host.

device@1 (extends ui@^1) — simulators, emulators, phones

OperationInput → Output
device.info{} → { platform, osVersion, model, orientation, buttons }★
device.app.install{ path } → { app: ref } (effects, once)
device.app.launch{ app | bundleId } → { app: ref } (effects)★
device.app.terminate{ app } (effects)
device.openUrl{ url } (effects)
device.button{ name } — one of device.info.buttons, else unsupported (effects)
device.rotate · device.appearance(effects)○
device.logs{ app?, since? } → { lines } (sense log)○
device.test.run{ plan } → { passed, failed, report: blob } (effects, once)○

Reads: device.summary ('turn'). Events: device.app.crashed (sense log, replay: 'durable'). Setup: { model?, osVersion? }. Platform-specific features — permission dialogs, intents — are extensions.

chat@1 — messaging workspaces

For agents that start conversations and wait on people. A bot that answers every message belongs on @noetic-tools/chat-sdk.

OperationInput → Output
chat.post{ channel: ref, text } → { message: ref, thread: ref } (effects, once)★
chat.reply{ thread, text } → { message: ref } (effects, once)★
chat.history{ channel? | thread?, cursor?, limit? } → { messages, cursor? } (sense state)★
chat.channels{ cursor? } → { channels: [{ channel: ref, name }], cursor? }
chat.react · chat.edit · chat.delete (guarded) · chat.users○

Text is CommonMark. chat.channel and chat.user refs are public. Reads: chat.thread.recent ('effect'). Events, sense state, replay: 'durable', thread: 'thread': chat.message.created (arguments channel?, thread?, mentionsAgent?), and ○ chat.message.updated, chat.reaction.added. Identity surface: x-chat.

Writing a Server

The rest of this page is for people implementing a profile.

Declaring a server

A server declares what it implements under tools.noetic/profiles in its MCP extensions capability:

{
  "tools.noetic/profiles": {
    "profiles": { "browser": "1.2.0", "ui": "1.1.0" },
    "extensions": { "com.example.probe": "1.0.0" },
    "senses": {
      "ui":  { "independence": "actuation-derived", "grade": "presence" },
      "net": { "independence": "independent", "grade": "consequence" },
      "log": { "independence": "independent", "grade": "context" }
    },
    "determinism": { "reset": "cheap", "replayPrefix": "free", "time": "wall", "observation": "exact", "actions": "reversible" },
    "setup": { "$ref": "#/setup" },
    "aliases": { "ui.find": { "tool": "probe_look", "fixed": { "action": "find" } } },
    "instances": [{ "id": "ctx-1", "title": "Chromium · localhost:5173" }]
  }
}
  • Profiles — each with every parent, inside the child's range.
  • Senses — OVP channel descriptors: each sense's independence and grade. Defaults are OVP's table; a server may declare a sense less trustworthy, never more. x- senses must declare both.
  • Determinism — OVP's determinism profile as a Determinism. A profile sets a default — a browser { reset: 'cheap', replayPrefix: 'free', time: 'wall', … }, chat { reset: 'none', replayPrefix: 'unsafe', …, actions: 'irreversible' } — and a server may declare itself less forgiving, never more. It decides how a run resumes after the connection was replaced.
  • Aliases — by default an operation's MCP tool has the operation's name. An alias maps it onto a tool the server already has, with fixed arguments merged in, so an existing server can implement a profile without a second tool surface.
  • Instances — when one server serves several connections, requests carry _meta["tools.noetic/instance"].
  • Names use only letters, digits, _, -, and ., as MCP tool names require.

The mcp() bridge checks a declaration against the profile definitions you pass it — mcp({ ...server, profiles: [browser] }) — and marks a server whose tools contradict its declaration unavailable.

Parts every server provides

PartPurpose
noetic://identity and the identity.changed eventOVP subject { surface, instance, epoch? }. instance changes whenever the thing is replaced and is never constant; epoch is a non-negative integer that moves on code edits, absent when edits cannot be seen.
noetic.resetWhen determinism.reset is not none.
noetic.provision · noetic.releaseWhen the server can back a pool.
noetic://coverage?since=<cursor>Optional. OVP blind spots since a cursor, each naming its sense; a sense whose blind spots cannot be listed is left out.
noetic.detect({ since })Optional. OVP anomalies with their tier, set honestly.

Receipts and idempotency

Every effects operation returns a receipt and attributes the events it causes to its actionId. It never reports whether it worked. Every once operation returns its original outcome for a repeated _meta["tools.noetic/idempotency-key"] for at least 24 hours, and refuses the same key with different arguments with conflict.

Errors

A refused or failed operation returns an MCP tool error with structuredContent: { code, message, details? }. It reaches the harness as a connection_failed error carrying the code:

CodeMeaning
undeclaredNot an operation of this connection.
unsupportedThis connection cannot honestly do it for this target.
unresolvedThe target could not be found.
ambiguousA locator matched several targets; details.candidates lists them. Never pick one.
unavailableFound, but not in a state to accept this.
guardedRefused on purpose. The harness asks for approval and retries with _meta["tools.noetic/confirmed"], which only it can set.
malformedBad arguments. Never substitute a default.
stale_referenceA volatile ref no longer resolves.
not_found · permission_denied · rate_limited · busy · conflictAs named.

A server never approximates: what it cannot do exactly, it refuses.

Conformance

checkConformance({ server, profile, extensions, driver }) from @noetic-tools/mcp/conformance runs the profile's scenarios against a server. A driver written by the server's author causes what the server cannot — another user's message, a crash, a failing request.

import { checkConformance } from '@noetic-tools/mcp/conformance';
import { browser } from '@noetic-tools/profile-browser';

const report = await checkConformance({
  server: { command: 'npx', args: ['my-browser-mcp'] },
  profile: browser,
  driver: {
    sample: async (op) => (op === 'browser.navigate' ? { url: 'http://localhost:5173' } : undefined),
    healthyClaim: async () => 'yes',
  },
});
console.log(report);
  • The declaration is checked first, against what the server actually serves.
  • Every effects operation is confirmed through an independent sense; a server whose receipts describe actions that never happened fails.
  • One scenario is a healthy subject with a real claim and must be proved yes, so a server cannot pass by answering "unknown".
  • Ambiguous locators must be refused, sensitive fields masked on screen, summaries produced in the Senses formats, identity changed on replacement.
  • A scenario the driver cannot set up scores absent, never a pass.

The report names each failure with the profile part it comes from, and the OVP conformance level the declared senses reach.

On this page