# Munky integration

Munky learns from your product's events and coordinates work through capabilities you provide.
Your application keeps its own accounts, interface and business data. Munky owns the learning,
execution contracts and result checks. Attesko is a client of this API.

The [public guide and reference](https://api.munky.sh/docs/integration.md) are readable without an
account. The [API reference](API-REFERENCE.md) lists every public route, its authentication and validated
request examples. The deployed Markdown includes that reference and the local runtime guide below
the integration guide; relative file links refer to repository copies. This guide explains how the
pieces fit together.

## Choose your integration

| Need | Use today | Boundary |
| --- | --- | --- |
| Add learning and checked actions to existing software | Trusted backend + project key + tenant/principal mapping + public SDK | Keep your existing customer login. A tenant is a customer workspace, not a developer account. |
| Run supported work on a customer's computer | Register the tenant's Edge Runtime and deliver its scoped credential | Your app supplies native permissions, login UI, secure storage and supported execution adapters. Never distribute a project key. |
| Keep inference and private memory local | Embed the versioned local runtime and a separately qualified model | Runtime archive contains no model weights; external website tasks still need network access. |
| Use MUNKY-hosted end-user accounts and workspace membership | Application accounts, sessions and workspaces APIs through your trusted backend | Separate project/environment identity; explicit invitations and device enrollment. Existing `/product/v1/*` clients need migration. |

MUNKY OS and Attesko consume the same public engine. Their personal/business interfaces and curated
experiences are separate products; the API must still document every capability needed to implement
supported execution. Model quality, arbitrary app compatibility and ready-made provider connectors
must be qualified separately from a successful API request.

## Create a project and key

1. Open [the developer console](https://api.munky.sh/setup).
2. Create an account and enter the six-digit code sent to your email address.
3. Name your project, open **Keys**, and create a **development** key.
4. Save the key in your server's secret store. It is displayed once and cannot be retrieved later.

The intended API origin is `https://api.munky.sh`. During the current domain cutover, use
`https://munkyapi-production.up.railway.app` for the console, API and versioned downloads if the
custom domain does not resolve. Set the SDK's `baseUrl` to the same reachable origin. This temporary
host uses the same Munky service; domain cutover is not complete.

Project keys begin with `munky_sk_` and belong to one
project and environment. Browser sessions use a separate HttpOnly cookie; a project key cannot
sign in to the console. Never put project keys in browser JavaScript or a desktop installer.

```sh
curl --fail-with-body https://api.munky.sh/v1/whoami \
  -H "Authorization: Bearer $MUNKY_API_KEY"
```

The response identifies your project, environment, scopes and available services. Use development
tenants before production. The same external customer ID in two environments has separate state.

## Hosted accounts and workspaces (SDK 0.6.0)

Use this optional path when you want Munky to manage your application's login. Existing software
can keep its own accounts and use the tenant/principal/runtime APIs directly.

Your server needs a newly issued project key with `application-accounts:write` and
`application-workspaces:write`. Existing keys do not silently gain these scopes. Every request is
bound to that key's project and environment. Use different projects for MUNKY OS and Attesko; an
email shared by both products does not share an account, password, session or workspace.

```js
await munky.applicationAccounts.signup({ email, password });
// Show code entry in your app. Never log passwords or codes.
const login = await munky.applicationAccounts.verify({ email, code });
const sessionToken = login.session.token;
const workspace = await munky.applicationWorkspaces.create(sessionToken, {
  kind: "personal", // use "business" for a company workspace
  displayName: "My work",
});
const registration = await munky.applicationWorkspaces.enrollDevice(
  sessionToken, workspace.id, deviceCapabilities,
);
```

These calls belong on your trusted backend. Authenticate the incoming product request, preserve
its application/environment binding and store the session token securely. The backend forwards it
as `x-munky-application-session`; the SDK does this per call and never appends it to a URL. Return
only the appropriate session/device information to the authenticated product client. Use native
secure storage on desktop; for your own web session use an HttpOnly cookie and CSRF protection.
Never embed the project key or a shared session token in public JavaScript or an installer.

`deviceCapabilities` uses the registration schema from the reference. Start with execution `none`
until the app actually implements its declared adapters. The returned registration works with
`createEdgeRuntimeClient` as shown below. A session establishes identity, not purchase authority.
Workspace membership creates an engine principal with no approval permissions. Your integration
must deliberately grant appropriate permissions and implement the signed decision flow separately.

### Login, recovery and session lifecycle

- `applicationSessions.signIn({email,password})` issues an independent seven-day session.
- `applicationSessions.current(token)` reads the account/session, without returning its secret.
- `applicationSessions.revoke(token)` signs out that session; `revokeAll(token)` signs out all
  sessions for that account in that application/environment.
- `applicationAccounts.resend({email})` replaces a pending signup code.
- `startRecovery({email})` and `verifyRecovery({email,code})` recover access to an existing account.
- `changePassword(token,{currentPassword,newPassword})` replaces the password and revokes every
  session, including the caller. A session issued through recovery may omit `currentPassword`
  during its first 15 minutes. Afterwards, sign in again with the new password.

Email codes expire after 15 minutes and allow five wrong guesses. Sending is limited to three
messages per address per hour and 100 per application/environment per hour. Additional signin and
verification budgets apply; a 429 means stop and offer a later retry. Responses do not disclose
whether an address exists. Passwords and session tokens are stored as hashes. Session revocation
is persisted and checked by workspace mutations, including enrollment racing with sign-out.

### Company membership and devices

Use `applicationWorkspaces.list(token)` after login. Personal creation returns the same single-owner
workspace on repeat; a business create makes a new workspace. Business creation and other mutations
are not automatically retried after a lost response. Read the workspace/device list to reconcile
before asking for another operation.

Owners and admins read `members(token,workspaceId)` and create an invitation with
`invite(token,workspaceId,{email,role:"member"})`. Only the owner can invite an admin. The response
contains an invitation ID, expiry and single-use token. Your product delivers it privately; Munky
does not send invitation email from this endpoint. The invited person signs up/signs in to the same
application/environment and calls `acceptInvitation(token,{token:invitationToken})`. Only the exact
verified email can accept; email-domain matching never grants membership. Invitations expire after
seven days and can be revoked with `revokeInvitation(token,workspaceId,invitationId)`.

`removeMember(token,workspaceId,accountId)` revokes membership, device credentials and pending
invitations associated with that member. The owner cannot be removed. Admins cannot remove another
admin. Members see only their own devices; owners/admins can manage workspace devices. Ordinary
members can enroll observation-only devices. Owner/admin membership is required to enroll execution
adapters because current Edge assignments have workspace-wide reach. Fine-grained delegated employee
execution and ownership transfer are not part of this release.

### Pause, resume and data removal

`updatePolicy(token,workspaceId,{paused:true})` lets an owner/admin pause new runtime and backend
work leases, device observations and frame escalations. Read `policy(token,workspaceId)` or resume
with `paused:false`. Pause does not cancel already-issued work or stop a disconnected local process.
Reports, independent product events and result verification remain available to finish recording
work that already happened. Historical imports also remain available.

Tenant export includes workspace/membership/invitation/device metadata without secret tokens or
hashes. Tenant erasure removes that workspace and its device credentials. Application accounts and
sessions remain, since a person can belong to other workspaces; workspace erasure is not account
deletion. An owner can call `applicationWorkspaces.erase(token,workspaceId)` to erase a workspace;
admins and members cannot. Review the returned erasure record for retained audit records.

Use `applicationAccounts.delete(token,{currentPassword})` to delete the current account. As with
password replacement, recent mailbox recovery can replace password proof. Deletion returns 409
`application_account_owns_workspace` until every owned workspace is erased. It removes the scoped
account, password, sessions and memberships and revokes device credentials. Historical audit and
principal identifiers remain without usable authority; other applications and environments remain
untouched. Legacy product-account migration is still required.

## Native application gateway

A native product can use the optional gateway at `/v1/apps/:appId` without receiving a project
key. The operator configures each app ID with a separate server-held key; MUNKY OS uses `munky-os`
and a personal workspace. Attesko must use its own project and business configuration. These routes
reuse the public application accounts/workspaces and phone services; they do not restore
`/product/v1/*`. The generated reference lists each request body.

Use native HTTP networking, such as Swift `URLSession`. Browser requests with an `Origin` header
are refused, no browser CORS access is granted, and cookies are not forwarded. Keep the returned
application session in the platform's secure credential store. Send it only as
`x-munky-application-session`; never put it in a URL. Responses are not cacheable.

| Native gateway path, relative to `/v1/apps/:appId` | Method and input |
| --- | --- |
| `/status` | GET; returns `appId`, `available: true` and configured `workspaceKind`. Missing, revoked or insufficient server credentials return 503. |
| `/accounts/signup`, `/accounts/resend`, `/accounts/verify` | POST; same email/password or email/code schemas as application accounts. |
| `/accounts/recovery/start`, `/accounts/recovery/verify` | POST; email, then email/code. |
| `/sessions/sign-in` | POST email/password; returns account and application session. |
| `/sessions/current`, `/sessions/revoke` | POST `{}` with the session header; read current session or sign out. |
| `/workspaces` | GET lists active memberships; POST `{ "displayName": "My work" }` creates the configured kind. A client cannot supply `kind`; MUNKY OS is personal only. |
| `/workspaces/:workspaceId/devices` | GET lists permitted devices; POST uses `RegisterEdgeRuntimeInput` and returns the scoped runtime credential once. |
| `/workspaces/:workspaceId/devices/:runtimeId` | DELETE revokes a permitted device. |
| `/workspaces/:workspaceId/policy` | GET reads policy; PUT `{ "paused": true }` pauses new work, subject to owner/admin authorization. |
| `/workspaces/:workspaceId/phone-challenges` | POST `{ "phone": "+15555550123", "consent": true }`; principal and tenant come from the caller's membership. |
| `/workspaces/:workspaceId/phone-challenges/:challengeId/verify` | POST `{ "code": "123456" }`. |
| `/workspaces/:workspaceId/phone-contact` | GET reads the caller's masked contact; DELETE revokes it. |
| `/workspaces/:workspaceId/interruptions` | GET for owners/admins; accepts only `status`, `limit`, `cursor` query parameters. Returns the existing uncertainty inbox shape. |
| `/workspaces/:workspaceId/receipts` | GET for owners/admins; optional `limit`. Returns the existing receipt list. |

All workspace, phone and inbox routes require the application-session header. The gateway checks
current membership and derives tenant/principal identifiers for phone and inbox requests; clients
cannot select another principal. The gateway exposes no arbitrary proxy URL, project credential,
work-claim endpoint or approval bypass. Use the enrolled runtime for computer work and the documented
signed decision contract for approvals. A readable interruption is not authority to resolve it.

## Recover console access

Open `/recover`, enter your developer email and use the six-digit code from your inbox. The code
expires after 15 minutes and allows five incorrect attempts. A successful code signs you into your
existing project; it can be consumed only once, including simultaneous requests. Requesting another
code replaces the old code. The response does not disclose whether an email has an account.

Recovery does not change the password or sign out other browsers. It clears a password-attempt
lockout. Password replacement and server-side session revocation are separate unfinished account
features; do not promise them in a client or label this flow “reset password.”

## Use the SDK

Install the versioned SDK package directly from Munky:

```sh
npm install https://api.munky.sh/sdk/munky-sdk-0.8.0.tgz
```

The package includes its public types and runtime code. You do not need access to Munky's private
repository. Keep the resolved integrity hash in your package lockfile.

```js
import { createMunkyClient } from "@munky/sdk";

const munky = createMunkyClient({
  apiKey: process.env.MUNKY_API_KEY,
  baseUrl: "https://api.munky.sh",
  edge: { pseudonymizationKey: process.env.MUNKY_PROJECTION_KEY },
});

const tenant = await munky.tenants.upsert({
  externalId: "customer_2048",
  displayName: "Northwind",
});

const event = await munky.events.track({
  tenantId: tenant.tenantId,
  source: "billing",
  type: "invoice.updated",
  occurredAt: new Date().toISOString(),
  actor: { id: "user_382" },
  subject: { type: "invoice", id: "invoice_8291" },
  changedFields: ["status"],
  before: { status: "overdue" },
  after: { status: "follow_up_sent" },
  correlationId: "follow_up_8291",
});
```

`event.status` is `accepted` or `duplicate`. `unmapped` means the event has been stored but its
meaning has not been confirmed. `removedFieldCount` reports fields excluded by the local projector.
HTTP 200 alone does not mean every event in a batch was accepted: inspect each item.

Your projection key must contain at least 32 characters. Keep it stable and private. The SDK
uses it to replace record and person identifiers with tenant-specific pseudonyms before sending
an event. It allows operational state fields and removes names, contact details, free text and
recognized secrets. Rotating the key changes the identifiers and breaks continuity with old events.

Keep your native event names. Do not invent workflows or send screenshots through the event API.
Unknown event types remain unmapped until their semantics are explicitly confirmed through
`POST /v1/tenants/:tenantId/events/aliases`. Historical import uses `/events/import` and does not
replay old cases as new work.

## Browser and native WebView imports

SDK 0.2.0 provides browser-compatible entries with public declarations:

| Import | Use |
| --- | --- |
| `@munky/sdk/contracts` | Validate and type the shared wire messages |
| `@munky/sdk/edge-runtime` | Device registration, scheduling, events and work |
| `@munky/sdk/observation` | Local observation processing and explicit escalation contracts |
| `@munky/sdk/computer-use` | Scoped computer-use credential and action contracts |

The root `@munky/sdk` and `browser-session` entries run on Node. Do not import the root into a
WebView: it includes server signing helpers. Browser entries do not accept project keys. Supply
native capture, secure credential storage and execution ports through their declared interfaces.
The observation entry contains Munky-owned model coordination; using it does not qualify a model
or permit image upload without the user's configured cloud-help choice.

The same SDK archive includes `native/local-model`, a Rust library for numeric-loopback-only
model transport and experimental task/browser suggestions. Install the SDK before compiling the
native product, then point its Cargo dependency at that directory. Munky maintains the library;
the product supplies UI, capture, secure storage and task authority. The archive contains model
integrity manifests but no weights. Commit the npm/pnpm and Cargo lockfiles together.

SDK 0.3.0 adds `native/runtime-host`, which supervises the core, model and company relay.
The product supplies its identity, resource path and private storage path. Follow the bundled host
README for relay startup before enforcing the local network policy. The host binds request
credentials to its private origin; the product cannot retrieve those credentials.

Existing 0.1.0 and 0.2.0 archive URLs retain their original bytes for pinned consumers.

## Numbered text decisions (SDK 0.4.0)

Install `https://api.munky.sh/sdk/munky-sdk-0.4.0.tgz` to use the Node-only
`@munky/sdk/text-decisions` entry. It also runs in a trusted local Node host; it needs no cloud call
or provider API key to format and interpret a decision. Existing browser entries are unchanged.

`prepareTextDecision(input)` validates a two-to-four-option request and returns `{ version, input,
body, bindingHash }`. Use the exported `TextDecisionInput` type or `TextDecisionInputSchema`. Each
option supplies a stable `id`, plain-language `label` and `consequence`, an `intent` (`preference`,
`purchase` or `decline`), an exact `planHash` and a quote or `null`. Always include a decline choice.
Yes/No mode has exactly two options: the proposed action first, decline second.

Purchase quotes require provider, product, quantity, currency, total due now in minor units, known
tax treatment, renewal amount/interval/start or no recurrence, material terms, source URL and quote
timestamps. USD, EUR, GBP, CAD and AUD are supported; amounts use two minor-unit decimal places.
The request window cannot outlast its quote. Unknown tax is allowed only for a preference, which
must not result in a purchase. Source URLs and prices are supplied evidence, not verified by the SDK.

Persist the exact packet before delivery. After authenticating the recipient and correlating the
provider reply to that request, call `interpretTextReply(packet, reply, now)`. Supply the stored
`requestId`, `tenantId`, `principalId`, `conversationId` and `bindingHash` from trusted routing state,
plus the reply `body`. A selected result returns the stable `optionId`, `intent`, `planHash` and
`bindingHash`; it is not itself an authorized case decision. Revalidate current prices/authority and
atomically persist the existing bound decision before dispatch. Refused results cause no action.

Never fill correlation fields from the newest pending task merely because a plain SMS says “1”.
The transport still needs reliable request correlation, signature/identity checks, durable inbox and
outbox state, replay protection and delivery acceptance. These services are not supplied by this
formatting module. See the text decision section of the repository's `docs/AUTOMATION-PLAN.md`.

## Stored text decisions

Create a queued decision with `POST /v1/tenants/:tenantId/text-decisions` using the SDK input
shape above and `proposals:write`. The tenant and principal must be existing UUIDs in your project
and environment. Keep the same request ID, timestamps and complete input when retrying; an exact
retry returns the stored request, while changed content returns `text_decision_conflict`.

Read it with `GET /v1/tenants/:tenantId/text-decisions/:requestId` (`proposals:read`). Cancel an
unanswered request with `POST /v1/tenants/:tenantId/text-decisions/:requestId/cancel`, sending
`{ "bindingHash": "<stored packet hash>" }` and `proposals:write`. Successful responses are not
cacheable. The generated reference includes complete request bodies and errors.

These routes store decisions. To deliver one, first obtain the user's consent and create a phone
challenge with `POST /v1/tenants/:tenantId/principals/:principalId/phone-challenges`, supplying
`{ "phone": "+15555550123", "consent": true }`. Enter the received code through
`POST /v1/tenants/:tenantId/principals/:principalId/phone-challenges/:challengeId/verify` with
`{ "code": "123456" }`. Both require `principals:write`; never guess or display the verification code
from server state. Phone possession binds a contact, not authority to purchase.

Read the masked contact through `GET /v1/tenants/:tenantId/principals/:principalId/phone-contact`;
DELETE the same route to revoke it. Send an existing request with
`POST /v1/tenants/:tenantId/text-decisions/:requestId/send`, its exact `bindingHash` and
`proposals:write`. New deliveries can include the full question, choices, prices and expiry directly
in the SMS, with a distinct numeric code for each option. Reply with the exact code shown, for example
`1001`. The transport binds that code to the stored request, option, packet hash and verified phone
contact; it never applies a bare `1` or `Yes` to whichever question happens to be newest. Codes are
not intentionally reused during normal retained-database operation. Database restoration requires
the code-allocation recovery gate in Operations before SMS is enabled again.

If the complete prompt (including codes, link and opt-out text) exceeds 1,200 characters, or contains
control characters that could confuse the option layout, delivery retains the private decision
link. Previously sent link-only messages also keep their original behavior. The project key never
receives the link token. Opening a link records no selection; the page offers the original numbered
or Yes/No buttons. Do not forward private links. Older mobile clients may still describe the link
path; direct codes are read from the text itself, not entered into the companion app.

STOP blocks texts across projects; START requires fresh phone verification and does not reactivate
old links or option codes. HELP explains how to use the option code or link shown in the message.
Providers must be configured as described in Operations; unavailable delivery returns
`text_delivery_unavailable`. The worker retains uncertain sends without blindly retrying. Provider
acceptance is not handset delivery. A late or invalid code does not select an option from a newer
question. Duplicate provider callbacks do not create another selection.

Selections remain distinct from an authorized case decision. Revalidate the exact plan, current
quote, authority, principal and account before dispatch and independently verify the outcome.
Account/device integration and the existing case-decision handoff still need acceptance. There is
no public project-key route to impersonate a phone reply or approve a purchase. Tenant export and
erasure cover contacts, challenges and decision records while excluding private tokens.

SDK 0.5.0 wraps this flow as `munky.phoneContacts.startVerification`, `verify`, `get` and `revoke`,
and `munky.textDecisions.create`, `get`, `send` and `cancel`. These methods run on a trusted server
with the scoped project key. Verification sends/code submissions are not automatically retried;
a lost response must be resolved explicitly. Idempotent decision requests preserve their serialized
body across network retries. Existing published SDK versions remain available unchanged.

## Declare what your product can do

Create each capability with `POST /v1/capabilities`. A capability is one action with an argument
schema, allowed bindings, idempotency behavior, risk flags and an independent verifier. The
reference includes a complete request example.

Choose who performs it:

- **Your backend:** poll the tenant's work outbox, call your product, and report the attempt.
- **Munky Edge Runtime:** register an installation and declare the adapters and capability IDs
  it can execute. The runtime receives only work that matches its tenant and ready adapters.

For verification, declare either `PRODUCT_EVENT` or `SIGNED_HTTP_READ`. A product event must
show the authoritative state change. A signed read must query that state independently of the
performer's report. Register its connection and allowed operations before using it. Production
and staging require HTTPS; development can use loopback HTTP. The verifier URL must be reachable
from the MUNKY server, not just from your browser. A loopback provider works when the API and provider
run on the same host; a hosted API cannot reach a service on your laptop through `localhost`.

## Learn, review and perform

1. Send real native events with stable IDs, timestamps and correlation IDs.
2. Read unmapped types, episodes and learned jobs. Run `/learn` when an immediate pass is needed.
3. Supply human evaluation labels and inspect `/accuracy`; missing evidence does not pass a gate.
4. Show the exact proposal in your product and obtain an authorized decision bound to its plan hash.
5. Approve a trial with its capability set, case limit and expiry. A project key by itself is not
   a person's approval: use a registered delegated-principal signing key and the SDK's authority helpers.
6. Present a separate case approval for each customer-facing or money-moving case.
7. Claim work, perform it idempotently, and report `performed`, `refused` or `failed`.
8. Read the receipt after Munky checks the independent event or signed read.

`performed` is not success. A successful receipt requires verification. Unknown targets, missing
inputs and changed state become work uncertainties; the runtime must not guess a replacement.
An authorized decision must bind the exact uncertainty and its current `bindingHash`.

Proposals, trials, case decisions, work, receipts, corrections, labels and learning feedback all
have public routes. Work metrics describe jobs and checked outcomes, never rankings of people.
Stop a trial through its stop endpoint; revoking a runtime prevents it receiving further work.

## Register and use an approval-signing key

A project key authenticates your backend. A separate Ed25519 signature binds an authenticated
person's decision to one exact resource. Create the signing key on your trusted server:

```sh
umask 077
openssl genpkey -algorithm ED25519 -out munky-approval-private.pem
openssl pkey -in munky-approval-private.pem -pubout -out munky-approval-public.pem
```

Keep the private file in your secret manager, outside source control. In the developer console,
open Keys, select the matching environment, and use **Approval-signing key** to register a unique
key ID and the public PEM. Project owners/admins register this trust relationship through their
console session; a project API key cannot establish its own human approval authority.

Create the principal with explicit permissions such as `case.approve`, narrowed to supported
capability IDs. Role labels alone grant nothing. Your backend must authenticate its user, resolve
their tenant/principal, display the exact pending decision, and persist their explicit response
before signing. Do not take principal IDs, approval objects or an approved flag straight from an
untrusted browser request. Re-read the pending approval and confirm the displayed plan is unchanged.

The following server helper signs that already-authenticated decision. Its arguments come from
trusted server state; it is not an unauthenticated approval endpoint:

```js
import { randomUUID } from "node:crypto";
import { signDelegatedPrincipalAssertion } from "@munky/sdk";

function signCaseDecision({ projectId, environment, principalId, approval, keyId, privateKey }) {
  const issuedAt = Math.floor(Date.now() / 1000);
  return signDelegatedPrincipalAssertion({
    keyId,
    privateKey,
    claims: {
      assertionId: randomUUID(),
      projectId,
      environment,
      tenantId: approval.tenantId,
      principalId,
      issuedAt,
      expiresAt: issuedAt + 120,
      operation: "case.approve",
      authorizationScope: { capabilityIds: approval.capabilityIds },
      resourceType: "case-approval",
      resourceId: approval.approvalId,
      bindingHash: approval.planHash,
    },
  });
}
```

Pass the resulting string as `principalAssertion` to
`munky.caseApprovals.approve(approval.approvalId, { principalId, principalAssertion,
planHash: approval.planHash, reviewMinutes: 0 })`. This requires `trials:write` on the project key
and `case.approve` on the principal. Use the actual review duration if you collect it; zero is the
default. Decline uses `case.decline` and `munky.caseApprovals.decline`. Proposal approval uses its
own resource type, operation, plan and request schema from the reference; never reuse a case token.

Assertions expire, may live at most five minutes, and are consumed once. Wrong tenant, principal,
resource, capability, plan, environment or key fails authorization. If a response is lost, read the
approval/work state before retrying; do not invent another approval to bypass replay protection.
To rotate the signing key, register a new key ID, move signing to it and revoke the old key through
`POST /setup/assertion-keys/:keyId/revoke` using the authenticated console session and an
`environment` form field. This is a console operation, not a public project-key endpoint.

A selected phone preference is not this case decision. The complete phone-to-case handoff still
needs acceptance. Only a later independent product event or signed read can prove the action succeeded.

## Run Edge beside an application

The SDK exports `OfficialEdgeRuntime`, `createEdgeRuntimeClient` and
`EDGE_RUNTIME_PROTOCOL_VERSION`. Register through `munky.edgeRuntimes.register(tenantId, input)`.
Registration returns a runtime credential once. Store that credential on the device and persist
rotations through the client's `onCredentialRotated` callback. Keep the project key on your server.

Registration declares the platform, runtime version, observation adapters, execution adapters,
capability IDs and uncertainty channels. An observer supplies projected event envelopes. The
runtime handles health, heartbeat, assignment checks, observations, reports and uncertainty.
Adapters perform the actual actions.

Available execution adapters include recorded browser procedures, connector calls and MCP tools.
Configure only adapters your integration actually implements. Browser procedures require their own
authenticated context, explicit origins, structural checkpoints and an independent result check.
They do not attach to a person's existing browser profile. Connector and MCP adapters require
explicit call ports and allowed operations. Registering an adapter is not proof that every app
or task is supported. Current action adapters require attended execution declarations.

Frame escalation is an optional cloud operation: the runtime sends one redacted PNG to the
configured provider. It requires declared observation support, a supported prompt version and
an available tenant allowance. It is not part of fully local processing. The local model and
local core are separate Munky-owned runtime components; cloud API availability does not establish
offline availability for an integration.

### Device enrollment and renewal

Your backend first authenticates the customer using your own login, checks their workspace membership,
and resolves their tenant mapping. It then calls `munky.edgeRuntimes.register` with a random stable
installation ID and the adapters that installation actually supports. Do not accept a tenant ID or
capability list from a device without checking it against the signed-in customer's permissions.
Send the returned registration only to that authenticated device over TLS; never send the project key.

The device uses the browser-compatible entry and stores credentials in its native secure store:

```js
import { createEdgeRuntimeClient } from "@munky/sdk/edge-runtime";

const device = createEdgeRuntimeClient(registration, {
  baseUrl: apiOrigin,
  onCredentialRotated: async (credential) => {
    await secureStore.save(credential); // resolve only after durable storage
  },
});
await device.heartbeat({
  observedAt: new Date().toISOString(),
  runtimeVersion: appVersion,
  state: "online",
  adapterHealth: [],
  activeAssignmentIds: [],
});
```

`registration`, `apiOrigin`, `appVersion` and `secureStore` are supplied by your application. Implement
adapters and report their actual health before requesting work. Heartbeat may replace the 24-hour
credential during its final six hours; keep heartbeats running while online. After the credential
expires, authenticate with your product backend again and re-enroll the installation. Do not retry
an expired key indefinitely. A lost enrollment response requires authenticated reconciliation; the
registration request can rotate credentials and is not an exactly-once delivery promise.

A runtime key cannot call project administration routes. Device removal calls
`munky.edgeRuntimes.revoke(runtimeId)` from your backend and deletes the local stored credential.
Project/environment checks also apply to revocation. Offline revocation takes effect at the cloud
boundary when the device next contacts it; it cannot retroactively stop a disconnected local action.
Hosted account/workspace APIs below provide the managed path. Existing clients calling `/product/v1/*`
still need to migrate; these endpoints are not aliases for the old routes.

## Embed the local runtime

Download `https://api.munky.sh/runtime/munky-local-runtime-0.14.0.tgz` and its matching `.json`
checksum manifest. Pin the SHA-256 in your application build and verify it before extracting.
The [local runtime guide](LOCAL-RUNTIME.md) describes the private launch protocol and required
Node/Postgres toolchain. This archive contains Munky-owned services, not model weights or an OS
installer. Products consume the archive without building Munky's server source.

## Verify callbacks and retry safely

Use `verifyMunkyRequest` for signed connection calls and `verifyMunkyWebhook` for webhooks. Verify
the original request bytes before parsing JSON. Supply a durable nonce replay check and enforce
the timestamp window. Process repeated delivery IDs idempotently.

The current wire protocol retains versioned `attesko-http-1` and `x-attesko-*` signing fields for
compatibility. These are protocol identifiers, not a different API owner. Use the SDK's verifier
rather than renaming headers. A later protocol version must change both signer and verifier.

For 429 responses, honor `Retry-After`. Retry transient transport failures with a bounded delay;
reuse the operation's idempotency key. Do not retry an ambiguous effect as a new action. A changed
plan, expired approval, revoked principal or mismatched tenant needs a new valid decision.

## Export and remove customer data

Tenant export and audit export return data and evidence without credential material. Tenant erasure
is idempotent and returns deletion counts plus an explicit list of retained records. Audit evidence
and database backup retention are separate from active tenant data; inspect the returned manifest.
Never infer that deleting an application user also deleted a Munky tenant.

## Integration acceptance

A working integration must demonstrate signup and key issuance, tenant isolation, event projection,
capability registration, an authorized decision, one actual effect and an independently verified
receipt. Test retries, revoked access and a second tenant attempting to read the first tenant's data.

A fake provider or synthetic event fixture validates a contract. It does not establish general app
autonomy, customer deployment or a qualified task model. The clean-repository production cutover and
public-domain acceptance are still in progress.


## Mac background tasks and mobile interruptions (SDK 0.7.0)

The Mac runs supported work through its enrolled runtime. Your backend can read pending runtime
interruptions without sharing a runtime key with the phone:

```js
let cursor;
do {
  const page = await munky.workUncertainties.list(tenantId, {
    status: "open", limit: 20, ...(cursor ? { cursor } : {}),
  });
  // Render the structural reason using your app's plain-language copy.
  // Check expiresAt; "open" alone does not prove the request is still answerable.
  showInterruptions(page.uncertainties);
  cursor = page.nextCursor;
} while (cursor);
```

This requires the backend's `uncertainty:decide` project scope. Authenticate the product user and
check their current workspace membership and decision permissions before returning any inbox data.
Project keys never belong in mobile apps. Cursors are opaque pagination positions bound to the
project, environment, tenant and status filter. Restart from page one when refreshing; newly arriving
items can precede an existing cursor. The inbox is read-only and never claims work or grants approval.

The runtime raises a structured uncertainty while holding an active assignment. The engine holds
that work. Show the exact task/account and reason; use the existing signed principal assertion and
`workUncertainties.resolve(id,{principalId,principalAssertion,bindingHash,resolution})` for retry or
cancel. A retry releases the unchanged held work for a fresh assignment; it does not change the plan,
renew an expired approval or establish that login succeeded. Each hold has its own identity; a
delayed retry/cancel for a previous hold cannot release or cancel a later one. The runtime must recheck provider
identity/session and preconditions before performing any effect. A new target, scope or purchase
quote needs a new exact decision. A performed report still requires independent verification.

Recommended product flow: Mac reports login needed → backend inbox → authenticated mobile decision
screen → provider's real authentication → Mac verifies connection → fresh assignment → verified
receipt. Opening a notification is never a decision. A mobile approval cannot satisfy a third-party
passkey/Touch ID challenge by itself. The secure screen must handle denial, expiry and cancellation.

Current implementation: interruption storage, public inbox, signed resolution, scoped runtime work
and independent receipts. Not yet implemented: APNs delivery, Apple passkey handoff, general
persistent browser-login management or complete Namecheap/Railway/email connectors. SDK browser
execution currently uses isolated contexts, not a user's automatically shared Safari session. Build
those connections explicitly; do not describe an adapter interface or engineer-operated browser as
MUNKY OS completing a task. Native permissions and the app's visible task/stop controls remain client
responsibilities. Background execution is unobtrusive, not hidden from the user's task history.


## iOS companion identity and decisions (SDK 0.8.0)

The companion uses the same application account as the Mac, with a separate application session
and mobile credential. It never receives the Mac runtime key, browser cookies, provider secrets
or the integration's project key. A mobile device is not an execution runtime.

A trusted backend calls the routes below with its project key (`application-workspaces:write`)
and the user's `x-munky-application-session`. MUNKY OS clients use the configured native gateway
at `/v1/apps/munky-os` instead: replace `/v1/application-mobile-devices` with `/mobile-devices`
and `/v1/application-workspaces` with `/workspaces`. The gateway supplies the project key.

| Method and route | Purpose |
| --- | --- |
| `POST /v1/application-mobile-devices` | Register or rotate this installation's mobile credential. |
| `GET /v1/application-mobile-devices` | List this account's phone registrations, including revoked devices. |
| `GET /v1/application-mobile-devices/current` | Validate the session and mobile credential together. |
| `DELETE /v1/application-mobile-devices/:deviceId` | Revoke the phone credential and remove its stored push token. |
| `GET /v1/application-workspaces/:workspaceId/mobile-decisions` | Read up to the latest 50 decisions addressed to this workspace member. |
| `GET /v1/application-workspaces/:workspaceId/mobile-decisions/:requestId` | Read one exact decision addressed to this member. |

Registration body:

```json
{
  "installationId": "324f888c-7f83-4096-a1bd-d2ad46164ecf",
  "displayName": "My iPhone",
  "platform": "ios",
  "appVersion": "0.1.0"
}
```

Keep a stable installation UUID in the phone's Keychain. Registration returns
`{device,deviceToken}`; save the `mmob_` token in Keychain and never log it. Re-registering the same
account and installation returns the same device ID and invalidates the previous token. Tokens
are returned only on registration; list/current responses cannot recover them. Registration for
another account does not reuse the first account's identity. A valid new sign-in may register a
previously revoked installation again; revocation is not a permanent installation ban.

Current-device and decision reads also require `x-munky-mobile-device: mmob_...`. Both credentials
must belong to the same project, environment and account. An expired or revoked application
session fails even when the mobile token is valid. Device revocation invalidates that mobile token
across sessions. Changing accounts must discard the old session and mobile credential locally.
A missing, rotated or revoked phone token returns `401 mobile_device_invalid`; an unavailable
managed device returns `404 mobile_device_not_found`. An inaccessible exact decision returns
`404 mobile_decision_not_found`, and absent membership returns `404 application_workspace_not_found`.

An optional registration `push` object contains an APNs hexadecimal `token` and `environment`
(`sandbox` or `production`). Push tokens are encrypted at rest and never returned. Omitting `push`
on re-registration clears its prior token. Device responses report
`push: {registered: boolean, delivery: "not-configured"}`: token storage is implemented, APNs
sending and delivery acceptance are not. Request notification permission in context; a user can
use the companion without push permission.

Decision responses contain the exact request ID, question, context, numbered options, expiry,
binding hash, state and selection. They always report `canAnswer: false` and
`answerChannel: "sms-secure-link"`. Show the details read-only and direct the user to the secure
link in the existing decision text. There is no unsigned mobile answer endpoint or approval on
notification tap. A preference does not authorize a purchase, and a phone approval cannot satisfy
a provider's passkey challenge. Decisions belonging to another principal in the same company are
not returned. The list is limited to 50 without pagination; use the exact request route for older
known requests. Mobile pairing and read-only decisions do not establish DNS execution, APNs
notification delivery or an installed phone's end-to-end acceptance.

## Website task briefs and DNS evidence assessment

The source contracts now include `WebsiteTaskBriefSchema` and
`WebsiteProviderObservationsSchema`. `assessWebsiteTask` is a pure, advisory engine module; it has
no HTTP route, cloud persistence or live provider connector. It is not yet included in a newly
published SDK. Keep the user's goal and background on their Mac and send only a task they explicitly
select. Free text is untrusted context, never permission to spend or alter other systems.

A brief contains `goal`, `background`, registered-zone `domain`, `railwayProjectUrl`,
`railwayServiceUrl`, and `domainScope` (`root`, `www`, or `both`), with optional local `updatedAt`
metadata. IP addresses and numeric TLDs are rejected. Fixed guardrails default when
omitted: no spending, email changes or nameserver changes. Empty draft fields produce missing-context
results. Railway console URLs must select the exact project/service; the selected URL must also
identify the environment. An existing `*.up.railway.app` service URL can instead be resolved by an
authenticated provider read, with the environment selected in the project URL. Do not infer a
registered zone from the last two domain labels: `example.co.uk` requires the observed SLD/TLD.

The connector must authenticate fresh Namecheap and Railway reads before supplying observations.
Each includes provider account ID, read ID, timestamp and exact target. Railway evidence must supply
the actual routing record and verification TXT for every selected hostname, bound to project,
service and environment. Namecheap evidence must include the complete host list, DNS provider,
root-ALIAS capability, SLD/TLD, EmailType, TTL, MX preference and CAA flag/tag metadata. Missing,
stale (over five minutes), future-dated, mismatched or conflicting observations block the review.
Schema validation, `recordsComplete: true`, JSON timestamps and capability strings do **not** prove
provider authenticity. This module cannot promote client-supplied JSON to verified evidence.

A successful assessment returns a review containing the full proposed host list, exact preserved
email configuration, source read IDs, snapshot digest, canonical DNS before/after fingerprints and
a stable idempotency key. It reuses the existing `tools/domain-pilot/dns-plan.mjs` planner; it does
not maintain a second DNS merge algorithm. Existing records are preserved and conflicting routing
records are not silently replaced. `already-configured` only means the supplied DNS snapshot already
contains the required records; it does not prove website availability or certificate issuance.
`executionAuthorized` is always false, and blocked assessments contain no replacement review.

A future executor still needs authenticated re-reads, an exact authorized decision, protection against
concurrent changes, and independent DNS/TLS/HTTP verification. Recheck both the complete provider
snapshot (including EmailType) and canonical record fingerprint immediately before any write; a
request idempotency key is not a provider guarantee against repeated effects.
[Namecheap setHosts replaces omitted host records and supports ALIAS and CAA metadata](https://www.namecheap.com/support/api/methods/domains-dns/set-hosts/).
[Railway requires its actual routing record and verification TXT; root domains need a supported alias mechanism](https://docs.railway.com/integrations/api/manage-domains).

### Local Namecheap and Railway website reads

The local website worker uses authenticated HTTP provider clients in
`tools/domain-pilot/providers/`. Credentials must come from the product's secure storage; these
clients never read an engineer's CLI session or environment. They accept production HTTPS endpoints
only (explicit loopback HTTP transport exists for fixtures), disable redirects and return constant
error codes without provider response bodies. An authenticated read is evidence for a proposed
change, not permission to execute it.

`NamecheapConnector.readZone({sld,tld})` verifies the exact registered domain in the authenticated
account, requires Namecheap-managed DNS, and returns a complete host snapshot with mail mode, MX
priority and CAA metadata. Missing or unsupported metadata blocks the operation. In particular,
Namecheap's published `getHosts` example omits `EmailType`; a response that omits it returns
`namecheap_email_mode_unavailable`. The client does not infer a mail mode from MX records. API access
and the caller's whitelisted IPv4 are prerequisites; browser login alone does not supply them.

`RailwayConnector.readDomains({domain,projectId,serviceId,environmentId,domainScope})` returns
`{observation,statuses}`. Account tokens use the authenticated account ID. Project tokens first
verify their actual project/environment and use that scope as the observation's account identity.
The domain query binds all three target IDs and requires the selected custom domains to exist.
Routing values and TXT verification metadata come from Railway's response, never a guessed service
hostname. New records use a 300-second TTL. Multiple service domains omit the optional unambiguous
service-domain shortcut. Unsupported/missing fields or GraphQL errors stop inspection. This client
does not create custom domains, deploy services or treat certificate status as a verified website.

The Namecheap client's separate `prepare`/`apply` implementation is not exposed as a desktop action.
A consumer must provide exact digest-bound authorization and a durable write-ahead attempt callback
before its single `setHosts` call. It rereads the full snapshot before dispatch, preserves the mail
mode, and performs readback after acknowledged or ambiguous writes. `provider-state-matches` is
provider readback only; independent DNS/HTTPS verification is still required. Ambiguous outcomes
always return `retryAllowed:false`. Fixtures are not live-account acceptance.

Provider contracts checked against [Railway API authentication](https://docs.railway.com/integrations/api),
[Railway domain management](https://docs.railway.com/integrations/api/manage-domains),
[Namecheap getHosts](https://www.namecheap.com/support/api/methods/domains-dns/get-hosts/) and
[Namecheap setHosts](https://www.namecheap.com/support/api/methods/domains-dns/set-hosts/).
The TXT prefix and verification host handling also follow the
[official Railway CLI domain implementation](https://github.com/railwayapp/cli/blob/master/src/commands/domain.rs).


### Native app text decisions

Configured native gateways expose `POST /v1/apps/:appId/workspaces/:workspaceId/text-decisions`.
Use the public text-decision body without `tenantId`, `principalId` or `conversationId`; these are
set by the gateway: tenant/principal from the signed-in workspace member, conversation ID from the unique request ID. Supply the application session header,
never a project key from the desktop. The trusted gateway key needs `proposals:write` and
`proposals:read` for these operations.

Persist the returned request ID and packet binding hash before calling the request's `/send`
endpoint with `{bindingHash}`. Read that same request to await selection; `/cancel` accepts the
same binding hash. Reads, sends and cancellation are restricted to the requesting principal.
Delivery is queued, not guaranteed by the enqueue response. Reuse the request on transport retries.
Compare request, binding hash, plan hash, option, intent and expiry before resuming its exact step.
A setup preference can open the next setup screen; it cannot grant browser permissions or spending
rights. Chrome/macOS permission dialogs still require their own local confirmation.

Unpriced, non-purchase prompts can use short numbered or Yes/No replies when there is exactly
one active question for the verified phone. Multiple active questions are rejected. Ordinary SMS
cannot distinguish a delayed reply to a previous question: these replies choose preferences only,
never grant security-sensitive access, execution authority or purchases. Priced/purchase prompts
retain their existing bound decision flow. Legacy unique codes remain accepted for old messages.

# API reference

*Generated by `pnpm contract:generate` from the mounted routes, the zod schemas they parse with, and the scope and error definitions. Do not edit by hand.*

## Scopes

A newly minted project key receives every scope below. `GET /v1/whoami` is the authority for the key you hold.

- `application-accounts:write`
- `application-workspaces:write`
- `tenants:write`
- `principals:write`
- `events:write`
- `events:read`
- `capabilities:write`
- `connections:write`
- `learning:read`
- `learning:write`
- `proposals:write`
- `trials:write`
- `work:read`
- `work:write`
- `edge:register`
- `uncertainty:decide`
- `corrections:write`
- `metrics:read`
- `proposals:read`
- `receipts:read`
- `capabilities:read`
- `webhooks:write`
- `export:read`
- `erase:write`

## Routes

Every `/v1` route requires the credential named in the auth column. Project-key routes also name their required scope; Edge data-plane routes use the tenant-bound runtime key returned at registration.

### Application accounts

| Route | Purpose | Auth |
| --- | --- | --- |
| `POST /v1/application-accounts/signup` | Start email verification for an account in this application and environment. | `application-accounts:write` |
| `POST /v1/application-accounts/resend` | Replace the pending signup code within the email send limit. | `application-accounts:write` |
| `POST /v1/application-accounts/verify` | Verify email and issue a revocable application session. | `application-accounts:write` |
| `POST /v1/application-accounts/recovery/start` | Send a code to recover an existing application account. | `application-accounts:write` |
| `POST /v1/application-accounts/recovery/verify` | Recover the scoped account and issue a recovery session. | `application-accounts:write` |
| `POST /v1/application-sessions/sign-in` | Sign in to this application and environment. | `application-accounts:write` |
| `POST /v1/application-sessions/current` | Read the current application session without returning its secret. | `application-accounts:write + x-munky-application-session` |
| `POST /v1/application-sessions/revoke` | Revoke the current application session. | `application-accounts:write + x-munky-application-session` |
| `POST /v1/application-sessions/revoke-all` | Revoke every session for this account in this application and environment. | `application-accounts:write + x-munky-application-session` |
| `POST /v1/application-accounts/password` | Replace the password and revoke all account sessions. | `application-accounts:write + x-munky-application-session` |
| `DELETE /v1/application-accounts/current` | Delete the current application account after password or recent recovery proof; owned workspaces must be erased first. | `application-accounts:write + x-munky-application-session` |

### Mobile companion

| Route | Purpose | Auth |
| --- | --- | --- |
| `POST /v1/application-mobile-devices` | Register a companion installation and rotate its mobile credential. | `application-workspaces:write + x-munky-application-session` |
| `GET /v1/application-mobile-devices` | List the account’s companion devices without credentials or push tokens. | `application-workspaces:write + x-munky-application-session` |
| `GET /v1/application-mobile-devices/current` | Validate the current application session and mobile credential together. | `application-workspaces:write + x-munky-application-session + x-munky-mobile-device` |
| `DELETE /v1/application-mobile-devices/:deviceId` | Revoke a companion credential and clear its push token. | `application-workspaces:write + x-munky-application-session` |
| `GET /v1/application-workspaces/:workspaceId/mobile-decisions` | Read the latest 50 decisions addressed to this workspace principal. | `application-workspaces:write + x-munky-application-session + x-munky-mobile-device` |
| `GET /v1/application-workspaces/:workspaceId/mobile-decisions/:requestId` | Read one exact decision; answers use its SMS code or secure link. | `application-workspaces:write + x-munky-application-session + x-munky-mobile-device` |
| `POST /v1/apps/:appId/mobile-devices` | Register a companion installation and rotate its mobile credential. | `configured native application; x-munky-application-session` |
| `GET /v1/apps/:appId/mobile-devices` | List the account’s companion devices without credentials or push tokens. | `configured native application; x-munky-application-session` |
| `GET /v1/apps/:appId/mobile-devices/current` | Validate the current application session and mobile credential together. | `configured native application; x-munky-application-session + x-munky-mobile-device` |
| `DELETE /v1/apps/:appId/mobile-devices/:deviceId` | Revoke a companion credential and clear its push token. | `configured native application; x-munky-application-session` |
| `GET /v1/apps/:appId/workspaces/:workspaceId/mobile-decisions` | Read the latest 50 decisions addressed to this workspace principal. | `configured native application; x-munky-application-session + x-munky-mobile-device` |
| `GET /v1/apps/:appId/workspaces/:workspaceId/mobile-decisions/:requestId` | Read one exact decision; answers use its SMS code or secure link. | `configured native application; x-munky-application-session + x-munky-mobile-device` |

### Application workspaces

| Route | Purpose | Auth |
| --- | --- | --- |
| `GET /v1/application-workspaces/:workspaceId/policy` | Read whether new work and device observation are paused. | `application-workspaces:write + x-munky-application-session` |
| `PUT /v1/application-workspaces/:workspaceId/policy` | Pause or resume new work leases and device observation as owner or admin. | `application-workspaces:write + x-munky-application-session` |
| `DELETE /v1/application-workspaces/:workspaceId` | Erase an owned workspace and revoke its device credentials. | `application-workspaces:write + x-munky-application-session` |
| `GET /v1/application-workspaces` | List workspaces where this account has active membership. | `application-workspaces:write + x-munky-application-session` |
| `POST /v1/application-workspaces` | Create a personal or business workspace and its isolated engine tenant. | `application-workspaces:write + x-munky-application-session` |
| `GET /v1/application-workspaces/:workspaceId/members` | Read the workspace roster as owner or administrator. | `application-workspaces:write + x-munky-application-session` |
| `POST /v1/application-workspaces/:workspaceId/invitations` | Create an exact-email invitation; deliver the returned token through your product. | `application-workspaces:write + x-munky-application-session` |
| `DELETE /v1/application-workspaces/:workspaceId/invitations/:invitationId` | Revoke a pending invitation. | `application-workspaces:write + x-munky-application-session` |
| `POST /v1/application-invitations/accept` | Accept a single-use invitation for this verified account. | `application-workspaces:write + x-munky-application-session` |
| `DELETE /v1/application-workspaces/:workspaceId/members/:accountId` | Remove a membership and revoke its devices. | `application-workspaces:write + x-munky-application-session` |
| `GET /v1/application-workspaces/:workspaceId/devices` | List devices visible to this workspace member. | `application-workspaces:write + x-munky-application-session` |
| `POST /v1/application-workspaces/:workspaceId/devices` | Enroll an installation for the active member; execution requires owner or admin. | `application-workspaces:write + x-munky-application-session` |
| `DELETE /v1/application-workspaces/:workspaceId/devices/:runtimeId` | Revoke an authorized workspace device. | `application-workspaces:write + x-munky-application-session` |

### Capabilities, proposals, and trials

| Route | Purpose | Auth |
| --- | --- | --- |
| `POST /v1/capabilities` | Declare or restore a capability | `capabilities:write` |
| `GET /v1/capabilities` | List project capabilities | `capabilities:write` |
| `DELETE /v1/capabilities/:capabilityId` | Withdraw while retaining history | `capabilities:write` |
| `POST /v1/tenants/:tenantId/connections` | Register a signed-read connection | `connections:write` |
| `GET /v1/tenants/:tenantId/connections` | Connection summaries | `connections:write` |
| `GET /v1/tenants/:tenantId/case-approvals` | Pending and decided sensitive cases | `proposals:read` |
| `POST /v1/case-approvals/:approvalId/approve` | Approve one always-ask case | `trials:write` |
| `POST /v1/case-approvals/:approvalId/decline` | Decline one always-ask case | `trials:write` |
| `GET /v1/tenants/:tenantId/proposals` | Tenant proposals | `proposals:read` |
| `GET /v1/proposals/:proposalId` | One proposal | `proposals:read` |
| `POST /v1/proposals/:proposalId/approve` | Approve the exact plan and start a trial | `proposals:write` |
| `POST /v1/proposals/:proposalId/decline` | Decline with a structured reason | `proposals:write` |
| `GET /v1/tenants/:tenantId/trials` | Tenant trials | `proposals:read` |
| `GET /v1/trials/:trialId` | One trial | `proposals:read` |
| `POST /v1/trials/:trialId/stop` | Stop a trial | `trials:write` |

### Work, proof, and governance

| Route | Purpose | Auth |
| --- | --- | --- |
| `POST /v1/tenants/:tenantId/corrections` | Append a correction and relearn | `corrections:write` |
| `GET /v1/tenants/:tenantId/corrections` | Correction history | `learning:read` |
| `GET /v1/tenants/:tenantId/work` | Lease an ordered work page | `work:read` |
| `GET /v1/work/:workItemId` | One work item | `work:read` |
| `POST /v1/work/:workItemId/report` | Report performed, refused, or failed | `work:write` |
| `GET /v1/tenants/:tenantId/receipts` | Tenant receipts | `receipts:read` |
| `GET /v1/receipts/:receiptId` | One coherent receipt | `receipts:read` |
| `GET /v1/tenants/:tenantId/export` | Tenant data and evidence export | `export:read` |
| `GET /v1/audit/export` | Security audit and erasure export | `export:read` |
| `DELETE /v1/tenants/:tenantId` | Idempotent tenant erasure | `erase:write` |
| `GET /v1/tenants/:tenantId/metrics` | Tenant work metrics | `metrics:read` |
| `GET /v1/metrics` | Project aggregate work metrics | `metrics:read` |
| `GET /v1/usage` | Usage and plan records | `project key` |
| `POST /v1/tenants/:tenantId/text-decisions` | Persist an immutable numbered or Yes/No decision request; does not send messages or approve work. | `proposals:write` |
| `GET /v1/tenants/:tenantId/text-decisions/:requestId` | Read the exact prompt, delivery state and recorded selection. | `proposals:read` |
| `POST /v1/tenants/:tenantId/text-decisions/:requestId/cancel` | Cancel an unanswered decision bound to its displayed prompt. | `proposals:write` |
| `POST /v1/webhooks` | Register a signed project webhook; the secret is returned once | `webhooks:write` |
| `GET /v1/webhooks` | Webhook summaries, secrets omitted | `webhooks:write` |
| `DELETE /v1/webhooks/:endpointId` | Remove a webhook; delivery history is kept | `webhooks:write` |
| `GET /v1/webhooks/deliveries` | Recent delivery attempts | `webhooks:write` |
| `POST /v1/webhooks/deliveries/:eventId/replay` | Replay one eligible delivery by its UUID event id | `webhooks:write` |

### Native application

| Route | Purpose | Auth |
| --- | --- | --- |
| `POST /v1/apps/:appId/workspaces/:workspaceId/text-decisions` | Create a text choice bound to the signed-in workspace principal. | `configured native application; x-munky-application-session; workspace membership` |
| `GET /v1/apps/:appId/workspaces/:workspaceId/text-decisions/:requestId` | Read your exact text choice and selection. | `configured native application; x-munky-application-session; workspace membership` |
| `POST /v1/apps/:appId/workspaces/:workspaceId/text-decisions/:requestId/send` | Queue your saved choice for delivery to your verified phone. | `configured native application; x-munky-application-session; workspace membership` |
| `POST /v1/apps/:appId/workspaces/:workspaceId/text-decisions/:requestId/cancel` | Cancel your exact pending text choice. | `configured native application; x-munky-application-session; workspace membership` |

### Native application gateway

| Route | Purpose | Auth |
| --- | --- | --- |
| `GET /v1/apps/:appId/status` | Check configured native application availability | `none; configured native application` |
| `POST /v1/apps/:appId/accounts/signup` | Start native application signup | `none; configured native application` |
| `POST /v1/apps/:appId/accounts/resend` | Resend native signup verification | `none; configured native application` |
| `POST /v1/apps/:appId/accounts/verify` | Verify native signup and issue an application session | `none; configured native application` |
| `POST /v1/apps/:appId/accounts/recovery/start` | Start native account recovery | `none; configured native application` |
| `POST /v1/apps/:appId/accounts/recovery/verify` | Verify native account recovery | `none; configured native application` |
| `POST /v1/apps/:appId/sessions/sign-in` | Sign in to the native application | `none; configured native application` |
| `POST /v1/apps/:appId/sessions/current` | Read the current native application session | `x-munky-application-session; configured native application` |
| `POST /v1/apps/:appId/sessions/revoke` | Sign out the current native application session | `x-munky-application-session; configured native application` |
| `GET /v1/apps/:appId/workspaces` | Read native workspace workspace | `x-munky-application-session; configured native application` |
| `POST /v1/apps/:appId/workspaces` | Create or submit native workspace workspace | `x-munky-application-session; configured native application` |
| `GET /v1/apps/:appId/workspaces/:workspaceId/policy` | Read native workspace policy | `x-munky-application-session; configured native application` |
| `PUT /v1/apps/:appId/workspaces/:workspaceId/policy` | Update native workspace policy | `x-munky-application-session; configured native application` |
| `GET /v1/apps/:appId/workspaces/:workspaceId/devices` | Read native workspace devices | `x-munky-application-session; configured native application` |
| `POST /v1/apps/:appId/workspaces/:workspaceId/devices` | Create or submit native workspace devices | `x-munky-application-session; configured native application` |
| `DELETE /v1/apps/:appId/workspaces/:workspaceId/devices/:runtimeId` | Revoke native workspace :runtimeId | `x-munky-application-session; configured native application` |
| `GET /v1/apps/:appId/workspaces/:workspaceId/phone-contact` | Read native workspace phone-contact | `x-munky-application-session; configured native application` |
| `DELETE /v1/apps/:appId/workspaces/:workspaceId/phone-contact` | Revoke native workspace phone-contact | `x-munky-application-session; configured native application` |
| `POST /v1/apps/:appId/workspaces/:workspaceId/phone-challenges` | Create or submit native workspace phone-challenges | `x-munky-application-session; configured native application` |
| `POST /v1/apps/:appId/workspaces/:workspaceId/phone-challenges/:challengeId/verify` | Create or submit native workspace verify | `x-munky-application-session; configured native application` |
| `GET /v1/apps/:appId/workspaces/:workspaceId/interruptions` | Read native workspace interruptions | `x-munky-application-session; configured native application` |
| `GET /v1/apps/:appId/workspaces/:workspaceId/receipts` | Read native workspace receipts | `x-munky-application-session; configured native application` |

### Edge runtimes and uncertainty

| Route | Purpose | Auth |
| --- | --- | --- |
| `POST /v1/tenants/:tenantId/edge-runtimes` | Register a tenant-bound Edge runtime and return its credential once | `edge:register` |
| `GET /v1/tenants/:tenantId/edge-runtimes` | List the tenant's registered Edge runtimes | `edge:register` |
| `DELETE /v1/edge-runtimes/:runtimeId` | Revoke an Edge runtime and its credential | `edge:register` |
| `POST /v1/edge-runtimes/:runtimeId/heartbeat` | Report runtime and adapter health; rotate a near-expiry credential | `runtime key` |
| `POST /v1/edge-runtimes/:runtimeId/events` | Submit minimized events from a declared observation source | `runtime key` |
| `GET /v1/edge-runtimes/:runtimeId/work` | Lease work matching the runtime's declared execution adapters | `runtime key` |
| `POST /v1/edge-runtimes/:runtimeId/work/:workItemId/report` | Report a bounded Edge assignment as performed, refused, or failed | `runtime key` |
| `POST /v1/edge-runtimes/:runtimeId/work/:workItemId/uncertainties` | Pause an assignment and raise structural uncertainty for a person | `runtime key` |
| `GET /v1/edge-runtimes/:runtimeId/work-uncertainties` | List uncertainty records visible to this runtime | `runtime key` |
| `POST /v1/work-uncertainties/:uncertaintyId/resolve` | Resolve uncertainty with an exact delegated-principal decision | `uncertainty:decide` |
| `POST /v1/edge-runtimes/:runtimeId/frame-escalations` | Have one redacted frame read by a provider when the runtime's own tier cannot | `runtime key` |

### Edge runtime

| Route | Purpose | Auth |
| --- | --- | --- |
| `GET /v1/tenants/:tenantId/work-uncertainties` | Read a workspace interruption inbox. Query status defaults to open; limit is 1–50; pass nextCursor as cursor for the next page. Requires a trusted backend; no runtime key is needed. | `uncertainty:decide` |

### Evidence and learning

| Route | Purpose | Auth |
| --- | --- | --- |
| `POST /v1/tenants/:tenantId/labels` | Human evaluation label | `learning:write` |
| `GET /v1/tenants/:tenantId/labels` | Label history | `learning:read` |
| `GET /v1/tenants/:tenantId/accuracy` | Label-backed quality gate | `learning:read` |
| `POST /v1/tenants/:tenantId/events` | Live event or batch intake (Edge envelopes only) | `events:write` |
| `POST /v1/tenants/:tenantId/events/import` | Historical intake without replay proposals | `events:write` |
| `GET /v1/tenants/:tenantId/events` | Events, with since (ISO 8601) and limit (1 to 1,000) | `events:read` |
| `GET /v1/tenants/:tenantId/events/unmapped` | Unknown event shapes | `events:read` |
| `POST /v1/tenants/:tenantId/events/aliases` | Confirm tenant-specific semantics | `events:write` |
| `GET /v1/tenants/:tenantId/episodes` | Reconstructed episodes | `learning:read` |
| `POST /v1/tenants/:tenantId/learn` | Run a learning pass | `learning:write` |
| `GET /v1/tenants/:tenantId/learning` | Status and recent passes | `learning:read` |
| `GET /v1/tenants/:tenantId/learning-feedback` | Outcome, correction, drift, and proposal feedback | `learning:read` |
| `GET /v1/tenants/:tenantId/jobs` | Latest job-family summaries | `learning:read` |
| `GET /v1/tenants/:tenantId/jobs/:familyId/versions` | Contract history | `learning:read` |
| `GET /v1/tenants/:tenantId/jobs/:familyId/episodes` | Evidence for one family | `learning:read` |
| `GET /v1/tenants/:tenantId/jobs/:familyId` | Latest versioned job contract | `learning:read` |

### Identity and tenants

| Route | Purpose | Auth |
| --- | --- | --- |
| `POST /v1/tenants` | Idempotent tenant upsert | `tenants:write` |
| `GET /v1/tenants` | Project and environment tenant list | `tenants:write` |
| `GET /v1/tenants/:tenantId` | One tenant mapping | `tenants:write` |
| `POST /v1/tenants/:tenantId/principals` | Principal upsert with permissions | `principals:write` |
| `GET /v1/tenants/:tenantId/principals` | Tenant principal list | `principals:write` |
| `GET /v1/whoami` | Caller, scopes, environment, and available areas | `project key` |

### System

| Route | Purpose | Auth |
| --- | --- | --- |
| `GET /health` | Process liveness. No key. | none |
| `GET /ready` | Database readiness. No key. | none |
| `GET /docs/integration.md` | Public integration guide and complete API reference | none |
| `GET /docs/INTEGRATION.md` | Read public integration documentation. No key. | none |
| `GET /docs/API-REFERENCE.md` | Read public integration documentation. No key. | none |
| `GET /docs/LOCAL-RUNTIME.md` | Read public integration documentation. No key. | none |
| `GET /docs/public-contract.json` | Read public integration documentation. No key. | none |
| `GET /sdk/munky-sdk-0.1.0.tgz` | Download the standalone SDK. No key. | none |
| `GET /runtime/munky-local-runtime-0.1.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.1.0.json` | Read the local runtime archive checksum. No key. | none |
| `GET /sdk/munky-sdk-0.2.0.tgz` | Download the standalone SDK. No key. | none |
| `GET /runtime/munky-local-runtime-0.2.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.2.0.json` | Read the local runtime archive checksum. No key. | none |
| `GET /sdk/munky-sdk-0.3.0.tgz` | Download the standalone SDK. No key. | none |
| `GET /runtime/munky-local-runtime-0.3.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.3.0.json` | Read the local runtime archive checksum. No key. | none |
| `GET /sdk/munky-sdk-0.4.0.tgz` | Download the SDK with numbered and Yes/No text decision contracts. No key. | none |
| `GET /sdk/munky-sdk-0.5.0.tgz` | Download the SDK with verified phone contacts and durable decision delivery APIs. No key. | none |
| `GET /runtime/munky-local-runtime-0.4.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.4.0.json` | Read the local runtime archive checksum. No key. | none |
| `GET /runtime/munky-local-runtime-0.5.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.5.0.json` | Read the local runtime archive checksum. No key. | none |
| `GET /runtime/munky-local-runtime-0.6.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.6.0.json` | Read the local runtime archive checksum. No key. | none |
| `GET /runtime/munky-local-runtime-0.7.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.7.0.json` | Read the local runtime archive checksum. No key. | none |
| `GET /runtime/munky-local-runtime-0.8.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.8.0.json` | Read the local runtime archive checksum. No key. | none |
| `GET /runtime/munky-local-runtime-0.9.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.9.0.json` | Read the local runtime checksum. No key. | none |
| `GET /runtime/munky-local-runtime-0.10.0.tgz` | Download the local runtime package. No key. | none |
| `GET /runtime/munky-local-runtime-0.10.0.json` | Read the local runtime checksum. No key. | none |
| `GET /sdk/munky-sdk-0.7.0.tgz` | Download SDK 0.7.0 with the workspace interruption inbox. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.15.0.tgz` | Download runtime 0.15.0 with native application gateway contracts. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.15.0.json` | Read the runtime 0.15.0 checksum. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.14.0.tgz` | Download runtime 0.14.0 with exact-hold retry protection. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.14.0.json` | Read the runtime 0.14.0 checksum. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.13.0.tgz` | Download local runtime 0.13.0. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.13.0.json` | Read the runtime 0.13.0 checksum. No key. | `project key` |
| `GET /sdk/munky-sdk-0.6.0.tgz` | Download the public SDK package. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.11.0.tgz` | Download the local runtime package. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.11.0.json` | Read the local runtime checksum. No key. | `project key` |
| `GET /v1/docs` | This reference as Markdown, for a key holder | `project key` |

### Distribution

| Route | Purpose | Auth |
| --- | --- | --- |
| `GET /sdk/munky-sdk-0.8.0.tgz` | Download SDK 0.8.0 with companion identity and decision reads. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.18.0.tgz` | Download runtime 0.18.0 with the direct SMS decision migration. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.18.0.json` | Read the runtime 0.18.0 checksum. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.17.0.tgz` | Download runtime 0.17.0 tgz release. No key. | `project key` |
| `GET /runtime/munky-local-runtime-0.17.0.json` | Download runtime 0.17.0 json release. No key. | `project key` |

### Text delivery

| Route | Purpose | Auth |
| --- | --- | --- |
| `POST /v1/tenants/:tenantId/principals/:principalId/phone-challenges` | Send a rate-limited phone possession challenge after messaging consent. | `principals:write` |
| `POST /v1/tenants/:tenantId/principals/:principalId/phone-challenges/:challengeId/verify` | Verify the code and bind the phone to this principal; grants no purchasing authority. | `principals:write` |
| `GET /v1/tenants/:tenantId/principals/:principalId/phone-contact` | Read verification status and the last four digits only. | `principals:write` |
| `DELETE /v1/tenants/:tenantId/principals/:principalId/phone-contact` | Revoke this phone contact and invalidate its decision links. | `principals:write` |
| `POST /v1/tenants/:tenantId/text-decisions/:requestId/send` | Queue one SMS decision link for the verified contact. | `proposals:write` |
| `GET /text-decisions/answer` | Open the decision screen without consuming or answering a request. | none |
| `POST /text-decisions/describe` | Read the exact decision using the private token delivered to a verified phone. | `private phone-delivered token` |
| `POST /text-decisions/answer` | Record a selection using the phone-delivered token; does not place an order. | `private phone-delivered token and same-origin request` |
| `POST /v1/text-decisions/sms` | Authenticate Twilio STOP/START/HELP; plain replies never select a purchase. | `Twilio signature, account and destination` |

## Request bodies

Every POST route, with its schema, an example that validates against it, and what a first call needs to know.

### `POST /v1/application-mobile-devices`

- Store the returned mobile token in Keychain. Re-registration rotates it; push delivery is not configured.

Example:

```json
{
  "installationId": "324f888c-7f83-4096-a1bd-d2ad46164ecf",
  "displayName": "My iPhone",
  "platform": "ios",
  "appVersion": "0.1.0"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "installationId": {
      "type": "string",
      "format": "uuid",
      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
    },
    "displayName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "platform": {
      "type": "string",
      "const": "ios"
    },
    "appVersion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "push": {
      "type": "object",
      "properties": {
        "token": {
          "type": "string",
          "pattern": "^(?:[a-fA-F0-9]{2}){32,100}$"
        },
        "environment": {
          "type": "string",
          "enum": [
            "sandbox",
            "production"
          ]
        }
      },
      "required": [
        "token",
        "environment"
      ],
      "additionalProperties": false
    }
  },
  "required": [
    "installationId",
    "displayName",
    "platform",
    "appVersion"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/mobile-devices`

- Store the returned mobile token in Keychain. Re-registration rotates it; push delivery is not configured.

Example:

```json
{
  "installationId": "324f888c-7f83-4096-a1bd-d2ad46164ecf",
  "displayName": "My iPhone",
  "platform": "ios",
  "appVersion": "0.1.0"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "installationId": {
      "type": "string",
      "format": "uuid",
      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
    },
    "displayName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "platform": {
      "type": "string",
      "const": "ios"
    },
    "appVersion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "push": {
      "type": "object",
      "properties": {
        "token": {
          "type": "string",
          "pattern": "^(?:[a-fA-F0-9]{2}){32,100}$"
        },
        "environment": {
          "type": "string",
          "enum": [
            "sandbox",
            "production"
          ]
        }
      },
      "required": [
        "token",
        "environment"
      ],
      "additionalProperties": false
    }
  },
  "required": [
    "installationId",
    "displayName",
    "platform",
    "appVersion"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-workspaces`

- Requires a backend project key and the x-munky-application-session header. Never put credentials or invitation tokens in URLs.

Example:

```json
{
  "kind": "personal",
  "displayName": "My work"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "kind": {
      "type": "string",
      "enum": [
        "personal",
        "business"
      ]
    },
    "displayName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 120
    }
  },
  "required": [
    "kind",
    "displayName"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-workspaces/:workspaceId/invitations`

- Requires a backend project key and the x-munky-application-session header. Never put credentials or invitation tokens in URLs.

Example:

```json
{
  "email": "colleague@example.com",
  "role": "member"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    },
    "role": {
      "type": "string",
      "enum": [
        "admin",
        "member"
      ]
    }
  },
  "required": [
    "email",
    "role"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-invitations/accept`

- Requires a backend project key and the x-munky-application-session header. Never put credentials or invitation tokens in URLs.

Example:

```json
{
  "token": "mai_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "token": {
      "type": "string",
      "pattern": "^mai_[A-Za-z0-9_-]{43}$"
    }
  },
  "required": [
    "token"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-workspaces/:workspaceId/devices`

- Requires a backend project key and the x-munky-application-session header. Never put credentials or invitation tokens in URLs.

Example:

```json
{
  "runtimeExternalId": "installation-1",
  "kind": "customer-edge",
  "platform": "macos",
  "runtimeVersion": "1.0.0",
  "protocolVersion": "attesko.edge.runtime.v1",
  "features": {
    "observations": false,
    "execution": "none",
    "observationAdapters": [],
    "executionAdapters": [],
    "uncertaintyChannels": []
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "runtimeExternalId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200,
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
    },
    "kind": {
      "type": "string",
      "enum": [
        "attesko-desktop",
        "customer-edge",
        "server-edge"
      ]
    },
    "platform": {
      "type": "string",
      "enum": [
        "macos",
        "windows",
        "linux",
        "server"
      ]
    },
    "runtimeVersion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "protocolVersion": {
      "type": "string",
      "const": "attesko.edge.runtime.v1"
    },
    "features": {
      "type": "object",
      "properties": {
        "observations": {
          "type": "boolean"
        },
        "execution": {
          "type": "string",
          "enum": [
            "none",
            "adapter"
          ]
        },
        "observationAdapters": {
          "default": [],
          "maxItems": 20,
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "native-event",
                  "browser-dom",
                  "os-accessibility",
                  "screen-capture"
                ]
              },
              "sourceSystems": {
                "default": [],
                "maxItems": 100,
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 100,
                  "pattern": "^[a-z][a-z0-9_.-]*$"
                }
              }
            },
            "required": [
              "kind"
            ]
          }
        },
        "executionAdapters": {
          "default": [],
          "maxItems": 20,
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "native-api",
                  "mcp",
                  "browser-dom",
                  "os-accessibility"
                ]
              },
              "capabilityIds": {
                "default": [],
                "maxItems": 200,
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 100,
                  "pattern": "^[a-z][a-z0-9_.-]*$"
                }
              },
              "attendance": {
                "type": "string",
                "enum": [
                  "attended"
                ]
              }
            },
            "required": [
              "kind"
            ]
          }
        },
        "uncertaintyChannels": {
          "default": [],
          "maxItems": 4,
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "local-notification",
              "sms",
              "email",
              "webhook"
            ]
          }
        }
      },
      "required": [
        "observations",
        "execution"
      ]
    }
  },
  "required": [
    "runtimeExternalId",
    "kind",
    "platform",
    "runtimeVersion",
    "protocolVersion",
    "features"
  ]
}
```

</details>

### `PUT /v1/application-workspaces/:workspaceId/policy`

- Requires a backend project key and the x-munky-application-session header. Never put credentials or invitation tokens in URLs.

Example:

```json
{
  "paused": true
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "paused": {
      "type": "boolean"
    }
  },
  "required": [
    "paused"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-accounts/signup`

- Requires a backend project key with application-accounts:write.

Example:

```json
{
  "email": "person@example.com",
  "password": "example-password-long-enough"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    },
    "password": {
      "type": "string",
      "minLength": 10,
      "maxLength": 1024
    }
  },
  "required": [
    "email",
    "password"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-accounts/resend`

- Requires a backend project key with application-accounts:write.

Example:

```json
{
  "email": "person@example.com"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    }
  },
  "required": [
    "email"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-accounts/verify`

- Requires a backend project key with application-accounts:write.

Example:

```json
{
  "email": "person@example.com",
  "code": "123456"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    },
    "code": {
      "type": "string",
      "pattern": "^\\d{6}$"
    }
  },
  "required": [
    "email",
    "code"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-accounts/recovery/start`

- Requires a backend project key with application-accounts:write.

Example:

```json
{
  "email": "person@example.com"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    }
  },
  "required": [
    "email"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-accounts/recovery/verify`

- Requires a backend project key with application-accounts:write.

Example:

```json
{
  "email": "person@example.com",
  "code": "123456"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    },
    "code": {
      "type": "string",
      "pattern": "^\\d{6}$"
    }
  },
  "required": [
    "email",
    "code"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-sessions/sign-in`

- Requires a backend project key with application-accounts:write.

Example:

```json
{
  "email": "person@example.com",
  "password": "example-password-long-enough"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    },
    "password": {
      "type": "string",
      "minLength": 1,
      "maxLength": 1024
    }
  },
  "required": [
    "email",
    "password"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-sessions/current`

- Requires a backend project key with application-accounts:write. Also requires the x-munky-application-session header; never put the token in a URL.

Example:

```json
{}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-sessions/revoke`

- Requires a backend project key with application-accounts:write. Also requires the x-munky-application-session header; never put the token in a URL.

Example:

```json
{}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-sessions/revoke-all`

- Requires a backend project key with application-accounts:write. Also requires the x-munky-application-session header; never put the token in a URL.

Example:

```json
{}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}
```

</details>

### `DELETE /v1/application-accounts/current`

- Requires the application session header. Current password may be omitted only within 15 minutes of mailbox recovery. Owned workspaces must be erased first.

Example:

```json
{
  "currentPassword": "example-password-long-enough"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "currentPassword": {
      "type": "string",
      "minLength": 1,
      "maxLength": 1024
    }
  },
  "additionalProperties": false
}
```

</details>

### `POST /v1/application-accounts/password`

- Requires a backend project key with application-accounts:write. Also requires the x-munky-application-session header; never put the token in a URL.

Example:

```json
{
  "currentPassword": "old-example-password",
  "newPassword": "new-example-password"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "currentPassword": {
      "type": "string",
      "minLength": 1,
      "maxLength": 1024
    },
    "newPassword": {
      "type": "string",
      "minLength": 10,
      "maxLength": 1024
    }
  },
  "required": [
    "newPassword"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/tenants/:tenantId/principals/:principalId/phone-challenges`

- Only send after the user requests phone setup. At most three challenges per phone per hour; five code guesses per challenge. Codes expire after ten minutes.

Example:

```json
{
  "phone": "+15555550123",
  "consent": true
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "phone": {
      "type": "string",
      "pattern": "^\\+[1-9][0-9]{7,14}$"
    },
    "consent": {
      "type": "boolean",
      "const": true
    }
  },
  "required": [
    "phone",
    "consent"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/tenants/:tenantId/principals/:principalId/phone-challenges/:challengeId/verify`

Example:

```json
{
  "code": "123456"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "code": {
      "type": "string",
      "pattern": "^[0-9]{6}$"
    }
  },
  "required": [
    "code"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/tenants/:tenantId/text-decisions/:requestId/send`

- Requires an existing verified contact. Returns queue state, not handset delivery. No link token is returned to the project key.

Example:

```json
{
  "bindingHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "bindingHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$"
    }
  },
  "required": [
    "bindingHash"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /text-decisions/describe`

- Token is carried in the SMS URL fragment and POST body, never a query string. This read does not answer the question.

Example:

```json
{
  "token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "token": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_-]{43}$"
    }
  },
  "required": [
    "token"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /text-decisions/answer`

- Use the exact displayed choice and configured HTTPS Origin. Recorded selection is not an existing case approval or purchase receipt.

Example:

```json
{
  "token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "answer": "Yes"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "token": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_-]{43}$"
    },
    "answer": {
      "type": "string",
      "enum": [
        "1",
        "2",
        "3",
        "4",
        "Yes",
        "No"
      ]
    }
  },
  "required": [
    "token",
    "answer"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/text-decisions/sms`

- Twilio application/x-www-form-urlencoded request, not JSON. Signature covers the configured public URL and all fields. Duplicate form fields are rejected; provider message IDs are deduplicated.

Example:

```json
{
  "AccountSid": "ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "MessageSid": "SMbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "From": "+15555550123",
  "To": "+15555550124",
  "Body": "HELP"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "propertyNames": {
    "type": "string"
  },
  "additionalProperties": {
    "type": "string"
  }
}
```

</details>

### `POST /v1/tenants/:tenantId/text-decisions`

- Replace the tenant/principal IDs and timestamps with current values. Reuse the exact input for retries.
- Creation queues a record only. There is no project-key endpoint that turns a reply into spending authority.

Example:

```json
{
  "requestId": "email-choice-1",
  "tenantId": "8a522c0b-a2b4-4e56-bd77-f9322d4f83a1",
  "principalId": "497f2c2f-9fea-4028-812f-41e2a76e8f08",
  "conversationId": "request-thread-1",
  "question": "Prepare email options?",
  "context": "Your existing email will stay as it is.",
  "mode": "yes-no",
  "recommendation": null,
  "createdAt": "2026-09-06T12:00:00Z",
  "expiresAt": "2026-09-06T12:15:00Z",
  "options": [
    {
      "id": "prepare",
      "label": "Prepare options",
      "consequence": "Compare mailbox providers.",
      "intent": "preference",
      "planHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "quote": null
    },
    {
      "id": "wait",
      "label": "Wait",
      "consequence": "Leave things unchanged.",
      "intent": "decline",
      "planHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
      "quote": null
    }
  ]
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "requestId": {
      "type": "string",
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
    },
    "tenantId": {
      "type": "string",
      "format": "uuid",
      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
    },
    "principalId": {
      "type": "string",
      "format": "uuid",
      "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
    },
    "conversationId": {
      "type": "string",
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
    },
    "question": {
      "type": "string",
      "minLength": 1,
      "maxLength": 300
    },
    "context": {
      "type": "string",
      "minLength": 1,
      "maxLength": 300
    },
    "mode": {
      "type": "string",
      "enum": [
        "numbered",
        "yes-no"
      ]
    },
    "options": {
      "minItems": 2,
      "maxItems": 4,
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "maxLength": 300
          },
          "consequence": {
            "type": "string",
            "minLength": 1,
            "maxLength": 300
          },
          "intent": {
            "type": "string",
            "enum": [
              "preference",
              "purchase",
              "decline"
            ]
          },
          "planHash": {
            "type": "string",
            "pattern": "^[a-f0-9]{64}$"
          },
          "quote": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 300
                  },
                  "product": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 300
                  },
                  "quantity": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 10000
                  },
                  "currency": {
                    "type": "string",
                    "enum": [
                      "USD",
                      "EUR",
                      "GBP",
                      "CAD",
                      "AUD"
                    ]
                  },
                  "totalDueNowMinor": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991
                  },
                  "tax": {
                    "type": "string",
                    "enum": [
                      "included",
                      "not-applicable",
                      "unknown"
                    ]
                  },
                  "recurring": {
                    "anyOf": [
                      {
                        "type": "object",
                        "properties": {
                          "totalMinor": {
                            "type": "integer",
                            "minimum": 0,
                            "maximum": 9007199254740991
                          },
                          "interval": {
                            "type": "string",
                            "enum": [
                              "month",
                              "year"
                            ]
                          },
                          "startsAt": {
                            "type": "string",
                            "format": "date-time",
                            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
                          }
                        },
                        "required": [
                          "totalMinor",
                          "interval",
                          "startsAt"
                        ],
                        "additionalProperties": false
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "terms": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 300
                  },
                  "sourceUrl": {
                    "type": "string",
                    "format": "uri"
                  },
                  "retrievedAt": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
                  },
                  "expiresAt": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
                  }
                },
                "required": [
                  "provider",
                  "product",
                  "quantity",
                  "currency",
                  "totalDueNowMinor",
                  "tax",
                  "recurring",
                  "terms",
                  "sourceUrl",
                  "retrievedAt",
                  "expiresAt"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "id",
          "label",
          "consequence",
          "intent",
          "planHash",
          "quote"
        ],
        "additionalProperties": false
      }
    },
    "recommendation": {
      "anyOf": [
        {
          "type": "object",
          "properties": {
            "optionId": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
            },
            "reason": {
              "type": "string",
              "minLength": 1,
              "maxLength": 300
            }
          },
          "required": [
            "optionId",
            "reason"
          ],
          "additionalProperties": false
        },
        {
          "type": "null"
        }
      ]
    },
    "createdAt": {
      "type": "string",
      "format": "date-time",
      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
    },
    "expiresAt": {
      "type": "string",
      "format": "date-time",
      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
    }
  },
  "required": [
    "requestId",
    "tenantId",
    "principalId",
    "conversationId",
    "question",
    "context",
    "mode",
    "options",
    "recommendation",
    "createdAt",
    "expiresAt"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/tenants/:tenantId/text-decisions/:requestId/cancel`

- Supply the bindingHash returned by create/read; this example hash is illustrative.

Example:

```json
{
  "bindingHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "bindingHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$"
    }
  },
  "required": [
    "bindingHash"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/tenants`

Example:

```json
{
  "externalId": "customer_2048",
  "displayName": "Northwind Logistics"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "externalId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "displayName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "metadata": {
      "type": "object",
      "propertyNames": {
        "type": "string"
      },
      "additionalProperties": {}
    }
  },
  "required": [
    "externalId",
    "displayName"
  ]
}
```

</details>

### `POST /v1/tenants/:tenantId/principals`

- `permissions` is a list of objects, not strings. Each names one `operation`; `capabilityIds` narrows it to an allow-list.
- `role` is your own label. Munky authorizes permissions, never role names.

Example:

```json
{
  "externalId": "user_17",
  "displayName": "Ops lead",
  "role": "billing_manager",
  "permissions": [
    {
      "operation": "proposal.approve"
    },
    {
      "operation": "case.approve",
      "capabilityIds": [
        "invoice.send_follow_up"
      ]
    }
  ]
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "externalId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "displayName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "role": {
      "default": "member",
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "permissions": {
      "default": [],
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "operation": {
            "type": "string",
            "enum": [
              "proposal.approve",
              "proposal.decline",
              "trial.stop",
              "case.approve",
              "case.decline",
              "correction.apply",
              "evaluation.label",
              "uncertainty.resolve",
              "connector-job.register"
            ]
          },
          "capabilityIds": {
            "minItems": 1,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1
            }
          }
        },
        "required": [
          "operation"
        ]
      }
    }
  },
  "required": [
    "externalId",
    "displayName"
  ]
}
```

</details>

### `POST /v1/tenants/:tenantId/events`

- Send the envelope the Edge SDK produces. Every identifier is a `psn_v1_` pseudonym; state values that are not Munky's own vocabulary are pseudonyms too; only allow-listed field names survive.
- One envelope, or `{ "events": [ ...envelopes ] }` with 1 to 1,000 items. The route tenant is authoritative.
- Per-item rejection codes: `privacy_rejected`, `tenant_mismatch`, `schema_invalid`, `store_failed`. The request succeeds (200) whenever it is well formed; only the request itself being malformed is a 422.

Developer input (`@munky/sdk` `events.track`):

```json
{
  "tenantId": "8786c412-4591-4a47-bb0a-be5440bd4e09",
  "source": "billing",
  "type": "invoice.updated",
  "occurredAt": "2026-09-02T15:00:00.000Z",
  "actor": {
    "id": "user-382"
  },
  "subject": {
    "type": "invoice",
    "id": "invoice-8291"
  },
  "changedFields": [
    "status"
  ],
  "before": {
    "status": "overdue"
  },
  "after": {
    "status": "follow_up_sent"
  },
  "correlationId": "chase-8291"
}
```

What the SDK sends:

```json
{
  "schemaVersion": "attesko.edge.event.v1",
  "privacy": {
    "pseudonymization": "hmac-sha256",
    "keyId": "primary",
    "removedFieldCount": 0
  },
  "event": {
    "tenantId": "8786c412-4591-4a47-bb0a-be5440bd4e09",
    "principalExternalId": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    "sourceSystem": "billing",
    "eventType": "invoice.updated",
    "occurredAt": "2026-09-02T15:00:00.000Z",
    "sourceRecordIds": [],
    "entities": [],
    "subjectExternalId": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    "subjectType": "invoice",
    "beforeState": {
      "status": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
    },
    "afterState": {
      "status": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
    },
    "changedFields": [
      "status"
    ],
    "correlationId": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    "idempotencyKey": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "schemaVersion": {
      "type": "string",
      "const": "attesko.edge.event.v1"
    },
    "privacy": {
      "type": "object",
      "properties": {
        "pseudonymization": {
          "type": "string",
          "const": "hmac-sha256"
        },
        "keyId": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_-]{0,99}$"
        },
        "removedFieldCount": {
          "type": "integer",
          "minimum": 0,
          "maximum": 9007199254740991
        }
      },
      "required": [
        "pseudonymization",
        "keyId",
        "removedFieldCount"
      ]
    },
    "event": {
      "type": "object",
      "properties": {
        "tenantId": {
          "type": "string",
          "minLength": 1
        },
        "eventId": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "principalExternalId": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "sourceSystem": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "eventType": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "occurredAt": {
          "type": "string",
          "minLength": 1
        },
        "sourceRecordIds": {
          "maxItems": 50,
          "type": "array",
          "items": {
            "type": "string",
            "maxLength": 400
          }
        },
        "entities": {
          "maxItems": 50,
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "minLength": 1,
                "maxLength": 100
              },
              "externalId": {
                "type": "string",
                "minLength": 1,
                "maxLength": 400
              },
              "label": {
                "type": "string",
                "maxLength": 400
              }
            },
            "required": [
              "type",
              "externalId"
            ]
          }
        },
        "subjectExternalId": {
          "type": "string",
          "maxLength": 400
        },
        "subjectType": {
          "type": "string",
          "maxLength": 100
        },
        "beforeState": {
          "type": "object",
          "propertyNames": {
            "type": "string"
          },
          "additionalProperties": {}
        },
        "afterState": {
          "type": "object",
          "propertyNames": {
            "type": "string"
          },
          "additionalProperties": {}
        },
        "changedFields": {
          "maxItems": 200,
          "type": "array",
          "items": {
            "type": "string",
            "maxLength": 200
          }
        },
        "correlationId": {
          "type": "string",
          "maxLength": 400
        },
        "causationId": {
          "type": "string",
          "maxLength": 400
        },
        "idempotencyKey": {
          "type": "string",
          "minLength": 1,
          "maxLength": 400
        },
        "provenance": {
          "type": "string",
          "enum": [
            "product-reported",
            "connected-system",
            "inference",
            "user-confirmed",
            "verification-evidence"
          ]
        },
        "sensitivity": {
          "type": "string",
          "enum": [
            "none",
            "business",
            "personal",
            "financial"
          ]
        },
        "metadata": {
          "type": "object",
          "propertyNames": {
            "type": "string"
          },
          "additionalProperties": {}
        }
      },
      "required": [
        "tenantId",
        "sourceSystem",
        "eventType",
        "occurredAt",
        "idempotencyKey"
      ]
    }
  },
  "required": [
    "schemaVersion",
    "privacy",
    "event"
  ]
}
```

</details>

### `POST /v1/tenants/:tenantId/events/import`

- Same body as live intake. Imports are marked historical and never raise a proposal.

Example:

```json
{
  "schemaVersion": "attesko.edge.event.v1",
  "privacy": {
    "pseudonymization": "hmac-sha256",
    "keyId": "primary",
    "removedFieldCount": 0
  },
  "event": {
    "tenantId": "8786c412-4591-4a47-bb0a-be5440bd4e09",
    "sourceSystem": "billing",
    "eventType": "invoice.sent",
    "occurredAt": "2026-06-01T10:00:00.000Z",
    "sourceRecordIds": [],
    "entities": [],
    "subjectExternalId": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    "subjectType": "invoice",
    "changedFields": [],
    "idempotencyKey": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "schemaVersion": {
      "type": "string",
      "const": "attesko.edge.event.v1"
    },
    "privacy": {
      "type": "object",
      "properties": {
        "pseudonymization": {
          "type": "string",
          "const": "hmac-sha256"
        },
        "keyId": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_-]{0,99}$"
        },
        "removedFieldCount": {
          "type": "integer",
          "minimum": 0,
          "maximum": 9007199254740991
        }
      },
      "required": [
        "pseudonymization",
        "keyId",
        "removedFieldCount"
      ]
    },
    "event": {
      "type": "object",
      "properties": {
        "tenantId": {
          "type": "string",
          "minLength": 1
        },
        "eventId": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "principalExternalId": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "sourceSystem": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "eventType": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "occurredAt": {
          "type": "string",
          "minLength": 1
        },
        "sourceRecordIds": {
          "maxItems": 50,
          "type": "array",
          "items": {
            "type": "string",
            "maxLength": 400
          }
        },
        "entities": {
          "maxItems": 50,
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "minLength": 1,
                "maxLength": 100
              },
              "externalId": {
                "type": "string",
                "minLength": 1,
                "maxLength": 400
              },
              "label": {
                "type": "string",
                "maxLength": 400
              }
            },
            "required": [
              "type",
              "externalId"
            ]
          }
        },
        "subjectExternalId": {
          "type": "string",
          "maxLength": 400
        },
        "subjectType": {
          "type": "string",
          "maxLength": 100
        },
        "beforeState": {
          "type": "object",
          "propertyNames": {
            "type": "string"
          },
          "additionalProperties": {}
        },
        "afterState": {
          "type": "object",
          "propertyNames": {
            "type": "string"
          },
          "additionalProperties": {}
        },
        "changedFields": {
          "maxItems": 200,
          "type": "array",
          "items": {
            "type": "string",
            "maxLength": 200
          }
        },
        "correlationId": {
          "type": "string",
          "maxLength": 400
        },
        "causationId": {
          "type": "string",
          "maxLength": 400
        },
        "idempotencyKey": {
          "type": "string",
          "minLength": 1,
          "maxLength": 400
        },
        "provenance": {
          "type": "string",
          "enum": [
            "product-reported",
            "connected-system",
            "inference",
            "user-confirmed",
            "verification-evidence"
          ]
        },
        "sensitivity": {
          "type": "string",
          "enum": [
            "none",
            "business",
            "personal",
            "financial"
          ]
        },
        "metadata": {
          "type": "object",
          "propertyNames": {
            "type": "string"
          },
          "additionalProperties": {}
        }
      },
      "required": [
        "tenantId",
        "sourceSystem",
        "eventType",
        "occurredAt",
        "idempotencyKey"
      ]
    }
  },
  "required": [
    "schemaVersion",
    "privacy",
    "event"
  ]
}
```

</details>

### `POST /v1/tenants/:tenantId/events/aliases`

- `surface` and `action` are Munky's vocabulary for what the event means; `confirmedByPrincipalId` is the Munky id of the person confirming it (from a principal upsert).

Example:

```json
{
  "eventType": "invoice.chased",
  "surface": "invoice",
  "action": "submitting-or-sending",
  "confirmedByPrincipalId": "prn_2f1c…",
  "note": "Our word for sending the reminder"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "eventType": {
      "type": "string",
      "minLength": 1
    },
    "surface": {
      "type": "string",
      "minLength": 1
    },
    "action": {
      "type": "string",
      "minLength": 1
    },
    "confirmedByPrincipalId": {
      "type": "string",
      "minLength": 1
    },
    "note": {
      "type": "string",
      "maxLength": 2000
    }
  },
  "required": [
    "eventType",
    "surface",
    "action",
    "confirmedByPrincipalId"
  ]
}
```

</details>

### `POST /v1/tenants/:tenantId/learn`

- No body fields. Send `{}`.

Example:

```json
{}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {}
}
```

</details>

### `POST /v1/tenants/:tenantId/labels`

- `kind` is one of: `same-family`, `same-variant`, `contract-intent`, `contract-inputs`, `contract-terminal-outcome`, `contract-executor`, `contract-verifier`, `contract-commit-boundary`, `contract-decision-points`, `contract-capability-mapping`, `case-eligibility`, `verifier-correctness`.
- Verdicts for `same-family`: `same-family`, `different-family`, `unsure`.
- Verdicts for `same-variant`: `same-variant`, `different-variant`, `unsure`.
- Verdicts for `contract-intent`: `correct`, `incorrect`, `unsure`.
- Verdicts for `contract-inputs`: `correct`, `incorrect`, `unsure`.
- Verdicts for `contract-terminal-outcome`: `correct`, `incorrect`, `unsure`.
- Verdicts for `contract-executor`: `correct`, `incorrect`, `unsure`.
- Verdicts for `contract-verifier`: `correct`, `incorrect`, `unsure`.
- Verdicts for `contract-commit-boundary`: `correct`, `incorrect`, `unsure`.
- Verdicts for `contract-decision-points`: `correct`, `incorrect`, `unsure`.
- Verdicts for `contract-capability-mapping`: `correct`, `incorrect`, `unsure`.
- Verdicts for `case-eligibility`: `correct`, `incorrect`, `unsure`.
- Verdicts for `verifier-correctness`: `correct`, `incorrect`, `unsure`.
- `subject` names what is being labelled: `{ familyId }` for a contract, `{ familyId, contractVersion }` for a field, `{ episodeIds: [a, b] }` for a pair kind.
- `principalAssertion` is a delegated-principal assertion signed with a registered Ed25519 key.

Example:

```json
{
  "kind": "contract-intent",
  "subject": {
    "familyId": "fam_7c2e…",
    "contractVersion": 1
  },
  "verdict": "correct",
  "note": "",
  "labelledByPrincipalId": "prn_2f1c…",
  "principalAssertion": "eyJhbGciOiJFZERTQSJ9.example.signature"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "kind": {
      "type": "string",
      "minLength": 1
    },
    "subject": {
      "type": "object",
      "propertyNames": {
        "type": "string"
      },
      "additionalProperties": {}
    },
    "verdict": {
      "type": "string",
      "minLength": 1
    },
    "note": {
      "default": "",
      "type": "string",
      "maxLength": 2000
    },
    "labelledByPrincipalId": {
      "type": "string",
      "minLength": 1
    },
    "principalAssertion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 32768
    }
  },
  "required": [
    "kind",
    "subject",
    "verdict",
    "labelledByPrincipalId",
    "principalAssertion"
  ]
}
```

</details>

### `POST /v1/capabilities`

Example:

```json
{
  "capabilityId": "invoice.send_follow_up",
  "displayName": "Send an invoice follow-up",
  "performs": {
    "action": "send",
    "entityType": "invoice",
    "surface": "billing"
  },
  "idempotent": true,
  "customerFacing": true,
  "moneyMoving": false,
  "reversible": false,
  "argumentSchema": {
    "type": "object",
    "required": [
      "invoiceId"
    ]
  },
  "argumentBindings": {
    "invoiceId": {
      "from": "subject.externalId"
    }
  },
  "verifier": {
    "kind": "PRODUCT_EVENT",
    "eventType": "invoice.updated",
    "windowMinutes": 60,
    "expect": [
      {
        "field": "status",
        "expect": "changed"
      }
    ]
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "capabilityId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100,
      "pattern": "^[a-z][a-z0-9_\\-.]*$"
    },
    "displayName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "performs": {
      "type": "object",
      "properties": {
        "action": {
          "type": "string",
          "minLength": 1
        },
        "entityType": {
          "type": "string",
          "minLength": 1
        },
        "surface": {
          "type": "string",
          "minLength": 1
        }
      },
      "required": [
        "action",
        "entityType"
      ]
    },
    "idempotent": {
      "type": "boolean"
    },
    "customerFacing": {
      "type": "boolean"
    },
    "moneyMoving": {
      "type": "boolean"
    },
    "reversible": {
      "default": false,
      "type": "boolean"
    },
    "argumentSchema": {
      "default": {
        "type": "object"
      },
      "type": "object",
      "propertyNames": {
        "type": "string"
      },
      "additionalProperties": {}
    },
    "argumentBindings": {
      "default": {},
      "type": "object",
      "propertyNames": {
        "type": "string"
      },
      "additionalProperties": {
        "oneOf": [
          {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "const": "subject.externalId"
              }
            },
            "required": [
              "from"
            ]
          },
          {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "const": "event.afterState"
              },
              "field": {
                "type": "string",
                "minLength": 1
              }
            },
            "required": [
              "from",
              "field"
            ]
          },
          {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "const": "event.beforeState"
              },
              "field": {
                "type": "string",
                "minLength": 1
              }
            },
            "required": [
              "from",
              "field"
            ]
          },
          {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "const": "event.metadata"
              },
              "field": {
                "type": "string",
                "minLength": 1
              }
            },
            "required": [
              "from",
              "field"
            ]
          },
          {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "const": "entity"
              },
              "entityType": {
                "type": "string",
                "minLength": 1
              }
            },
            "required": [
              "from",
              "entityType"
            ]
          },
          {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "const": "constant"
              },
              "value": {}
            },
            "required": [
              "from",
              "value"
            ]
          }
        ]
      }
    },
    "verifier": {
      "oneOf": [
        {
          "type": "object",
          "properties": {
            "kind": {
              "type": "string",
              "const": "PRODUCT_EVENT"
            },
            "eventType": {
              "type": "string",
              "minLength": 1
            },
            "windowMinutes": {
              "default": 60,
              "type": "integer",
              "exclusiveMinimum": 0,
              "maximum": 1440
            },
            "expect": {
              "default": [],
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "field": {
                    "type": "string",
                    "minLength": 1
                  },
                  "expect": {
                    "anyOf": [
                      {
                        "type": "string",
                        "const": "changed"
                      },
                      {
                        "type": "string",
                        "const": "present"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "equals": {}
                        },
                        "required": [
                          "equals"
                        ]
                      },
                      {
                        "type": "object",
                        "properties": {
                          "equalsArgument": {
                            "type": "string",
                            "minLength": 1
                          }
                        },
                        "required": [
                          "equalsArgument"
                        ]
                      }
                    ]
                  }
                },
                "required": [
                  "field",
                  "expect"
                ]
              }
            }
          },
          "required": [
            "kind"
          ]
        },
        {
          "type": "object",
          "properties": {
            "kind": {
              "type": "string",
              "const": "SIGNED_HTTP_READ"
            },
            "connectionId": {
              "type": "string",
              "minLength": 1
            },
            "operation": {
              "type": "string",
              "minLength": 1
            },
            "recordPath": {
              "type": "string"
            },
            "expect": {
              "default": [],
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "field": {
                    "type": "string",
                    "minLength": 1
                  },
                  "expect": {
                    "anyOf": [
                      {
                        "type": "string",
                        "const": "changed"
                      },
                      {
                        "type": "string",
                        "const": "present"
                      },
                      {
                        "type": "object",
                        "properties": {
                          "equals": {}
                        },
                        "required": [
                          "equals"
                        ]
                      },
                      {
                        "type": "object",
                        "properties": {
                          "equalsArgument": {
                            "type": "string",
                            "minLength": 1
                          }
                        },
                        "required": [
                          "equalsArgument"
                        ]
                      }
                    ]
                  }
                },
                "required": [
                  "field",
                  "expect"
                ]
              }
            }
          },
          "required": [
            "kind",
            "connectionId",
            "operation"
          ]
        }
      ]
    }
  },
  "required": [
    "capabilityId",
    "displayName",
    "performs",
    "idempotent",
    "customerFacing",
    "moneyMoving",
    "verifier"
  ]
}
```

</details>

### `POST /v1/tenants/:tenantId/connections`

- `signingSecret` is sealed on receipt and never returned.

Example:

```json
{
  "connectionId": "billing-read",
  "displayName": "Billing read API",
  "provider": "northwind",
  "baseUrl": "https://api.northwind.example",
  "operations": {
    "invoice": "/invoices/{id}"
  },
  "allowedOperations": [
    "invoice"
  ],
  "signingSecret": "a-signing-secret-of-at-least-32-characters"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "connectionId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "displayName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "provider": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "baseUrl": {
      "type": "string",
      "format": "uri"
    },
    "operations": {
      "type": "object",
      "propertyNames": {
        "type": "string"
      },
      "additionalProperties": {
        "type": "string",
        "pattern": "^\\/.*"
      }
    },
    "allowedOperations": {
      "minItems": 1,
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "healthPath": {
      "default": "/health",
      "type": "string",
      "pattern": "^\\/.*"
    },
    "signingSecret": {
      "type": "string",
      "minLength": 32
    },
    "scopes": {
      "default": [],
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "externalAccountId": {
      "type": "string"
    },
    "timeoutMs": {
      "default": 10000,
      "type": "integer",
      "exclusiveMinimum": 0,
      "maximum": 120000
    }
  },
  "required": [
    "displayName",
    "provider",
    "baseUrl",
    "operations",
    "allowedOperations",
    "signingSecret"
  ]
}
```

</details>

### `POST /v1/proposals/:proposalId/approve`

Example:

```json
{
  "planHash": "sha256:9f2c…",
  "principalId": "prn_2f1c…",
  "principalAssertion": "eyJhbGciOiJFZERTQSJ9.example.signature",
  "trial": {
    "maxCases": 20,
    "expiresAt": "2026-09-09T15:00:00.000Z",
    "reviewMinutesPerCase": 1
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "planHash": {
      "type": "string",
      "minLength": 1
    },
    "principalId": {
      "type": "string",
      "minLength": 1
    },
    "principalAssertion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 32768
    },
    "trial": {
      "type": "object",
      "properties": {
        "maxCases": {
          "type": "integer",
          "exclusiveMinimum": 0,
          "maximum": 1000
        },
        "expiresAt": {
          "type": "string"
        },
        "reviewMinutesPerCase": {
          "default": 0,
          "type": "number",
          "minimum": 0,
          "maximum": 600
        }
      },
      "required": [
        "maxCases"
      ]
    }
  },
  "required": [
    "planHash",
    "principalId",
    "principalAssertion",
    "trial"
  ]
}
```

</details>

### `POST /v1/proposals/:proposalId/decline`

- `reason` is one of: `not-this-job`, `wrong-path`, `not-safe-yet`, `wrong-capability`, `later`, `other`, `wrong-job`, `wrong-normal-path`, `unsafe`, `not-valuable`, `wrong-capability-mapping`, `wrong-timing`, `customer-policy`, `not-ready`.

Example:

```json
{
  "principalId": "prn_2f1c…",
  "principalAssertion": "eyJhbGciOiJFZERTQSJ9.example.signature",
  "reason": "not-safe-yet",
  "note": "Wait for the new verifier"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "principalId": {
      "type": "string",
      "minLength": 1
    },
    "principalAssertion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 32768
    },
    "reason": {
      "type": "string",
      "enum": [
        "not-this-job",
        "wrong-path",
        "not-safe-yet",
        "wrong-capability",
        "later",
        "other",
        "wrong-job",
        "wrong-normal-path",
        "unsafe",
        "not-valuable",
        "wrong-capability-mapping",
        "wrong-timing",
        "customer-policy",
        "not-ready"
      ]
    },
    "note": {
      "type": "string",
      "maxLength": 2000
    }
  },
  "required": [
    "principalId",
    "principalAssertion",
    "reason"
  ]
}
```

</details>

### `POST /v1/trials/:trialId/stop`

Example:

```json
{
  "principalId": "prn_2f1c…",
  "principalAssertion": "eyJhbGciOiJFZERTQSJ9.example.signature",
  "note": "Pausing during the audit"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "principalId": {
      "type": "string",
      "minLength": 1
    },
    "principalAssertion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 32768
    },
    "note": {
      "type": "string",
      "maxLength": 2000
    }
  },
  "required": [
    "principalId",
    "principalAssertion"
  ]
}
```

</details>

### `POST /v1/case-approvals/:approvalId/approve`

Example:

```json
{
  "principalId": "prn_2f1c…",
  "principalAssertion": "eyJhbGciOiJFZERTQSJ9.example.signature",
  "planHash": "sha256:9f2c…",
  "reviewMinutes": 2
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "principalId": {
      "type": "string",
      "minLength": 1
    },
    "principalAssertion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 32768
    },
    "planHash": {
      "type": "string",
      "minLength": 1
    },
    "reviewMinutes": {
      "default": 0,
      "type": "number",
      "minimum": 0
    },
    "note": {
      "type": "string",
      "maxLength": 2000
    }
  },
  "required": [
    "principalId",
    "principalAssertion",
    "planHash"
  ]
}
```

</details>

### `POST /v1/case-approvals/:approvalId/decline`

Example:

```json
{
  "principalId": "prn_2f1c…",
  "principalAssertion": "eyJhbGciOiJFZERTQSJ9.example.signature",
  "planHash": "sha256:9f2c…",
  "note": "Customer asked us to hold"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "principalId": {
      "type": "string",
      "minLength": 1
    },
    "principalAssertion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 32768
    },
    "planHash": {
      "type": "string",
      "minLength": 1
    },
    "reviewMinutes": {
      "default": 0,
      "type": "number",
      "minimum": 0
    },
    "note": {
      "type": "string",
      "maxLength": 2000
    }
  },
  "required": [
    "principalId",
    "principalAssertion",
    "planHash"
  ]
}
```

</details>

### `POST /v1/tenants/:tenantId/edge-runtimes`

- The response returns a short-lived `attsk_rt_` runtime credential once. Store it only in the runtime's local secret store.
- Adapter declarations describe reach; they do not establish that a browser or native actuator exists.
- Every declared execution adapter must carry `"attendance": "attended"`. That is the only value: no rung has earned unattended use, and an adapter that states nothing is refused here and dispatched no work.

Example:

```json
{
  "runtimeExternalId": "install-6f4d2a",
  "kind": "customer-edge",
  "platform": "macos",
  "runtimeVersion": "1.0.0",
  "protocolVersion": "attesko.edge.runtime.v1",
  "features": {
    "observations": true,
    "execution": "none",
    "observationAdapters": [
      {
        "kind": "native-event",
        "sourceSystems": [
          "billing"
        ]
      }
    ],
    "executionAdapters": [],
    "uncertaintyChannels": [
      "local-notification"
    ]
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "runtimeExternalId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200,
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
    },
    "kind": {
      "type": "string",
      "enum": [
        "attesko-desktop",
        "customer-edge",
        "server-edge"
      ]
    },
    "platform": {
      "type": "string",
      "enum": [
        "macos",
        "windows",
        "linux",
        "server"
      ]
    },
    "runtimeVersion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "protocolVersion": {
      "type": "string",
      "const": "attesko.edge.runtime.v1"
    },
    "features": {
      "type": "object",
      "properties": {
        "observations": {
          "type": "boolean"
        },
        "execution": {
          "type": "string",
          "enum": [
            "none",
            "adapter"
          ]
        },
        "observationAdapters": {
          "default": [],
          "maxItems": 20,
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "native-event",
                  "browser-dom",
                  "os-accessibility",
                  "screen-capture"
                ]
              },
              "sourceSystems": {
                "default": [],
                "maxItems": 100,
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 100,
                  "pattern": "^[a-z][a-z0-9_.-]*$"
                }
              }
            },
            "required": [
              "kind"
            ]
          }
        },
        "executionAdapters": {
          "default": [],
          "maxItems": 20,
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "native-api",
                  "mcp",
                  "browser-dom",
                  "os-accessibility"
                ]
              },
              "capabilityIds": {
                "default": [],
                "maxItems": 200,
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 100,
                  "pattern": "^[a-z][a-z0-9_.-]*$"
                }
              },
              "attendance": {
                "type": "string",
                "enum": [
                  "attended"
                ]
              }
            },
            "required": [
              "kind"
            ]
          }
        },
        "uncertaintyChannels": {
          "default": [],
          "maxItems": 4,
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "local-notification",
              "sms",
              "email",
              "webhook"
            ]
          }
        }
      },
      "required": [
        "observations",
        "execution"
      ]
    }
  },
  "required": [
    "runtimeExternalId",
    "kind",
    "platform",
    "runtimeVersion",
    "protocolVersion",
    "features"
  ]
}
```

</details>

### `POST /v1/edge-runtimes/:runtimeId/heartbeat`

- Authenticate with the runtime credential, not a project key.

Example:

```json
{
  "observedAt": "2026-09-03T15:00:00.000Z",
  "runtimeVersion": "1.0.0",
  "state": "online",
  "adapterHealth": [
    {
      "kind": "native-event",
      "state": "ready"
    }
  ],
  "activeAssignmentIds": []
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "observedAt": {
      "type": "string"
    },
    "runtimeVersion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "state": {
      "type": "string",
      "enum": [
        "online",
        "degraded",
        "paused"
      ]
    },
    "adapterHealth": {
      "default": [],
      "maxItems": 40,
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "kind": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "native-event",
                  "browser-dom",
                  "os-accessibility",
                  "screen-capture"
                ]
              },
              {
                "type": "string",
                "enum": [
                  "native-api",
                  "mcp",
                  "browser-dom",
                  "os-accessibility"
                ]
              }
            ]
          },
          "state": {
            "type": "string",
            "enum": [
              "ready",
              "degraded",
              "unavailable"
            ]
          },
          "reasonCode": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "pattern": "^[a-z][a-z0-9_.-]*$"
          }
        },
        "required": [
          "kind",
          "state"
        ],
        "additionalProperties": false
      }
    },
    "activeAssignmentIds": {
      "default": [],
      "maxItems": 50,
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1,
        "maxLength": 200,
        "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
      }
    }
  },
  "required": [
    "observedAt",
    "runtimeVersion",
    "state"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/edge-runtimes/:runtimeId/events`

- Authenticate with the runtime credential. The route tenant is bound by that credential, and the source system must have been declared at registration.
- One envelope, or `{ "events": [ ...envelopes ] }` with 1 to 1,000 items.

Example:

```json
{
  "schemaVersion": "attesko.edge.event.v1",
  "privacy": {
    "pseudonymization": "hmac-sha256",
    "keyId": "primary",
    "removedFieldCount": 0
  },
  "event": {
    "tenantId": "8786c412-4591-4a47-bb0a-be5440bd4e09",
    "sourceSystem": "billing",
    "eventType": "invoice.updated",
    "occurredAt": "2026-09-03T15:00:00.000Z",
    "sourceRecordIds": [],
    "entities": [],
    "subjectExternalId": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    "subjectType": "invoice",
    "changedFields": [
      "status"
    ],
    "idempotencyKey": "psn_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "schemaVersion": {
      "type": "string",
      "const": "attesko.edge.event.v1"
    },
    "privacy": {
      "type": "object",
      "properties": {
        "pseudonymization": {
          "type": "string",
          "const": "hmac-sha256"
        },
        "keyId": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_-]{0,99}$"
        },
        "removedFieldCount": {
          "type": "integer",
          "minimum": 0,
          "maximum": 9007199254740991
        }
      },
      "required": [
        "pseudonymization",
        "keyId",
        "removedFieldCount"
      ]
    },
    "event": {
      "type": "object",
      "properties": {
        "tenantId": {
          "type": "string",
          "minLength": 1
        },
        "eventId": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "principalExternalId": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "sourceSystem": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "eventType": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "occurredAt": {
          "type": "string",
          "minLength": 1
        },
        "sourceRecordIds": {
          "maxItems": 50,
          "type": "array",
          "items": {
            "type": "string",
            "maxLength": 400
          }
        },
        "entities": {
          "maxItems": 50,
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "minLength": 1,
                "maxLength": 100
              },
              "externalId": {
                "type": "string",
                "minLength": 1,
                "maxLength": 400
              },
              "label": {
                "type": "string",
                "maxLength": 400
              }
            },
            "required": [
              "type",
              "externalId"
            ]
          }
        },
        "subjectExternalId": {
          "type": "string",
          "maxLength": 400
        },
        "subjectType": {
          "type": "string",
          "maxLength": 100
        },
        "beforeState": {
          "type": "object",
          "propertyNames": {
            "type": "string"
          },
          "additionalProperties": {}
        },
        "afterState": {
          "type": "object",
          "propertyNames": {
            "type": "string"
          },
          "additionalProperties": {}
        },
        "changedFields": {
          "maxItems": 200,
          "type": "array",
          "items": {
            "type": "string",
            "maxLength": 200
          }
        },
        "correlationId": {
          "type": "string",
          "maxLength": 400
        },
        "causationId": {
          "type": "string",
          "maxLength": 400
        },
        "idempotencyKey": {
          "type": "string",
          "minLength": 1,
          "maxLength": 400
        },
        "provenance": {
          "type": "string",
          "enum": [
            "product-reported",
            "connected-system",
            "inference",
            "user-confirmed",
            "verification-evidence"
          ]
        },
        "sensitivity": {
          "type": "string",
          "enum": [
            "none",
            "business",
            "personal",
            "financial"
          ]
        },
        "metadata": {
          "type": "object",
          "propertyNames": {
            "type": "string"
          },
          "additionalProperties": {}
        }
      },
      "required": [
        "tenantId",
        "sourceSystem",
        "eventType",
        "occurredAt",
        "idempotencyKey"
      ]
    }
  },
  "required": [
    "schemaVersion",
    "privacy",
    "event"
  ]
}
```

</details>

### `POST /v1/edge-runtimes/:runtimeId/work/:workItemId/report`

- Authenticate with the runtime credential. `performed` is a report, not success; the core verifies independently.

Example:

```json
{
  "assignmentId": "assignment-8841",
  "bindingHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "outcome": "performed",
  "reference": "email_8841",
  "artifact": "Reminder sent for invoice 8291",
  "performedAt": "2026-09-03T15:01:12.000Z"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "outcome": {
      "type": "string",
      "enum": [
        "performed",
        "refused",
        "failed"
      ]
    },
    "reference": {
      "type": "string",
      "maxLength": 400
    },
    "artifact": {
      "type": "string",
      "maxLength": 4000
    },
    "detail": {
      "type": "string",
      "maxLength": 4000
    },
    "performedAt": {
      "type": "string"
    },
    "assignmentId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200,
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
    },
    "bindingHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$"
    }
  },
  "required": [
    "outcome",
    "assignmentId",
    "bindingHash"
  ]
}
```

</details>

### `POST /v1/edge-runtimes/:runtimeId/work/:workItemId/uncertainties`

- Send structural identifiers and reason codes only; do not send screenshots, DOM text, document content, or exception messages.

Example:

```json
{
  "assignmentId": "assignment-8841",
  "bindingHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "reason": "target-ambiguous",
  "reasonCode": "multiple-matching-records",
  "context": {
    "requiredFieldNames": [],
    "optionIds": [
      "record-1",
      "record-2"
    ],
    "candidateCount": 2
  },
  "observedAt": "2026-09-03T15:01:00.000Z"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "assignmentId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200,
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
    },
    "bindingHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$"
    },
    "reason": {
      "type": "string",
      "enum": [
        "missing-input",
        "target-ambiguous",
        "state-drift",
        "adapter-unavailable",
        "permission-required",
        "policy-blocked",
        "security-signal",
        "checkpoint-mismatch",
        "verification-ambiguous"
      ]
    },
    "reasonCode": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100,
      "pattern": "^[a-z][a-z0-9_.-]*$"
    },
    "context": {
      "default": {
        "requiredFieldNames": [],
        "optionIds": []
      },
      "type": "object",
      "properties": {
        "checkpointId": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200,
          "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
        },
        "requiredFieldNames": {
          "default": [],
          "maxItems": 20,
          "type": "array",
          "items": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "pattern": "^[a-z][a-z0-9_.-]*$"
          }
        },
        "optionIds": {
          "default": [],
          "maxItems": 20,
          "type": "array",
          "items": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
          }
        },
        "candidateCount": {
          "type": "integer",
          "minimum": 0,
          "maximum": 10000
        }
      },
      "additionalProperties": false
    },
    "observedAt": {
      "type": "string"
    }
  },
  "required": [
    "assignmentId",
    "bindingHash",
    "reason",
    "reasonCode",
    "observedAt"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/work-uncertainties/:uncertaintyId/resolve`

- The delegated-principal assertion and `bindingHash` bind the exact uncertainty decision.

Example:

```json
{
  "principalId": "prn_2f1c…",
  "principalAssertion": "eyJhbGciOiJFZERTQSJ9.example.signature",
  "bindingHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "resolution": {
    "kind": "select-option",
    "optionId": "record-1"
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "principalId": {
      "type": "string",
      "minLength": 1
    },
    "principalAssertion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 32768
    },
    "bindingHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$"
    },
    "resolution": {
      "oneOf": [
        {
          "type": "object",
          "properties": {
            "kind": {
              "type": "string",
              "const": "retry"
            }
          },
          "required": [
            "kind"
          ],
          "additionalProperties": false
        },
        {
          "type": "object",
          "properties": {
            "kind": {
              "type": "string",
              "const": "cancel"
            }
          },
          "required": [
            "kind"
          ],
          "additionalProperties": false
        },
        {
          "type": "object",
          "properties": {
            "kind": {
              "type": "string",
              "const": "select-option"
            },
            "optionId": {
              "type": "string",
              "minLength": 1,
              "maxLength": 200,
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
            }
          },
          "required": [
            "kind",
            "optionId"
          ],
          "additionalProperties": false
        }
      ]
    }
  },
  "required": [
    "principalId",
    "principalAssertion",
    "bindingHash",
    "resolution"
  ]
}
```

</details>

### `POST /v1/work/:workItemId/report`

- `outcome` is `performed`, `refused`, or `failed`. Never report success; Munky verifies.

Example:

```json
{
  "outcome": "performed",
  "reference": "email_8841",
  "artifact": "Reminder sent for invoice 8291",
  "performedAt": "2026-09-02T15:01:12.000Z"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "outcome": {
      "type": "string",
      "enum": [
        "performed",
        "refused",
        "failed"
      ]
    },
    "reference": {
      "type": "string",
      "maxLength": 400
    },
    "artifact": {
      "type": "string",
      "maxLength": 4000
    },
    "detail": {
      "type": "string",
      "maxLength": 4000
    },
    "performedAt": {
      "type": "string"
    }
  },
  "required": [
    "outcome"
  ]
}
```

</details>

### `POST /v1/tenants/:tenantId/corrections`

Example:

```json
{
  "kind": "correct-variant-assignment",
  "reason": "same-job-different-route",
  "principalId": "prn_2f1c…",
  "principalAssertion": "eyJhbGciOiJFZERTQSJ9.example.signature",
  "subject": {
    "familyId": "fam_7c2e…",
    "episodeId": "ep_1a9d…"
  },
  "payload": {
    "variantId": "normal"
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "kind": {
      "type": "string",
      "enum": [
        "merge-families",
        "split-family",
        "reassign-episode",
        "create-family-from-episodes",
        "alias-equivalent",
        "must-not-share-family",
        "reject-contract",
        "correct-intent",
        "correct-executor-mapping",
        "correct-verifier-mapping",
        "correct-input-fields",
        "correct-terminal-outcome",
        "correct-variant-assignment"
      ]
    },
    "reason": {
      "type": "string",
      "enum": [
        "different-business-outcome",
        "different-target-records",
        "different-side-effects",
        "same-job-different-names",
        "same-job-different-route",
        "wrong-operation-selected",
        "wrong-verification-selected",
        "wrong-inputs-inferred",
        "evidence-was-a-mixture",
        "provider-changed",
        "policy-changed",
        "operator-judgement"
      ]
    },
    "principalId": {
      "type": "string",
      "minLength": 1
    },
    "note": {
      "type": "string",
      "maxLength": 2000
    },
    "subject": {
      "type": "object",
      "propertyNames": {
        "type": "string"
      },
      "additionalProperties": {}
    },
    "payload": {
      "default": {},
      "type": "object",
      "propertyNames": {
        "type": "string"
      },
      "additionalProperties": {}
    },
    "principalAssertion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 32768
    }
  },
  "required": [
    "kind",
    "reason",
    "principalId",
    "subject",
    "principalAssertion"
  ]
}
```

</details>

### `POST /v1/webhooks`

- `eventTypes` are from: `job.understood`, `job.eligible`, `proposal.created`, `proposal.approved`, `proposal.declined`, `proposal.expired`, `trial.started`, `trial.exhausted`, `trial.stopped`, `trial.completed`, `work.issued`, `work.verified`, `work.failed`, `work.requires_human`, `receipt.created`, `correction.applied`, `connection.degraded`.
- Optional `secret` (32+ characters) to bring your own signing secret; otherwise one is generated and returned once.

Example:

```json
{
  "url": "https://example.com/webhooks/attesko",
  "eventTypes": [
    "proposal.created",
    "work.issued",
    "receipt.created"
  ]
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "url": {
      "type": "string",
      "format": "uri"
    },
    "eventTypes": {
      "minItems": 1,
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "job.understood",
          "job.eligible",
          "proposal.created",
          "proposal.approved",
          "proposal.declined",
          "proposal.expired",
          "trial.started",
          "trial.exhausted",
          "trial.stopped",
          "trial.completed",
          "work.issued",
          "work.verified",
          "work.failed",
          "work.requires_human",
          "receipt.created",
          "correction.applied",
          "connection.degraded"
        ]
      }
    },
    "secret": {
      "type": "string",
      "minLength": 32
    }
  },
  "required": [
    "url",
    "eventTypes"
  ]
}
```

</details>

### `POST /v1/webhooks/deliveries/:eventId/replay`

- No body fields. The event id in the path must be a UUID.

Example:

```json
{}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {}
}
```

</details>

### `POST /v1/edge-runtimes/:runtimeId/frame-escalations`

- For a runtime that runs its own frame tier and met a screen its history cannot answer. The runtime must have declared observation support when it registered; the tenant is the runtime's own and cannot be named in the body.
- `promptVersion` names the frame-understanding instruction to answer under. A prompt itself is never sent: Munky renders its own text for the version and refuses a version it does not hold, so a runtime cannot choose what a provider is asked.
- `evidence` must be the redactor's output. A `redactionVersion` of `raw`, `none`, `unredacted`, or `passthrough` is refused before any provider is called and before any allowance is spent. `frameEncoding` must be `png` — an escalation carries an encoded frame — and `frameBase64` is at most 2,000,000 characters, which keeps the whole request inside the 2 MiB body ceiling every other `/v1` route lives with.
- `escalationReason` is a lower-case slug describing why the local tier could not answer, matching `^[a-z][a-z0-9-]{0,40}$`. It is recorded on the departure notice, so it must never carry a window title, a URL, or anything else read off the screen.
- The answer is always 200 and is either `{ "ok": true, "output": …, "usage": … }` or `{ "ok": false, "reason": …, "detail": …, "retryAfterSeconds"?: … }`. `reason` is one of `unsupported-prompt-version`, `budget-exhausted`, `evidence-refused`, or `provider-unavailable`; only `provider-unavailable` should be retried with the same frame, and `budget-exhausted` resolves when the tenant's window does.
- The per-tenant escalation ceiling is spent when the frame is sent and is never refunded, including when the provider fails: what it bounds is frames leaving a machine, and one that left unanswered still left. A ceiling that cannot be read answers `budget-exhausted` too — see ADR-0043.
- Send `x-attesko-idempotency-key` and one frame is one escalation however many times it is sent. A key that has already been answered is answered again with the same `output` and `usage`, at no further cost and against no further allowance; a resend arriving while the first attempt is still running is refused `provider-unavailable` rather than read twice. Omitting the header does not opt out — Munky keys the frame by `evidence.recordId`, which is what the official runtime builds its header from. See ADR-0051.

Example:

```json
{
  "promptVersion": "frames-to-events-v8",
  "maxOutputTokens": 4096,
  "escalationReason": "insufficient-history",
  "evidence": {
    "recordId": "capture-8f21",
    "capturedAt": "2026-09-03T09:15:00.000Z",
    "width": 1440,
    "height": 900,
    "frameBase64": "iVBORw0KGgo=",
    "frameEncoding": "png",
    "redactionVersion": "redactor-v4",
    "activity": {
      "foregroundApplication": "Practice Manager",
      "windowTitle": null,
      "browserUrl": null,
      "pointerActivity": true,
      "keyboardActivity": false
    }
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "promptVersion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 120
    },
    "maxOutputTokens": {
      "type": "integer",
      "exclusiveMinimum": 0,
      "maximum": 9007199254740991
    },
    "escalationReason": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9-]{0,40}$"
    },
    "evidence": {
      "type": "object",
      "properties": {
        "recordId": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "capturedAt": {
          "type": "string",
          "minLength": 1,
          "maxLength": 40
        },
        "width": {
          "type": "integer",
          "exclusiveMinimum": 0,
          "maximum": 9007199254740991
        },
        "height": {
          "type": "integer",
          "exclusiveMinimum": 0,
          "maximum": 9007199254740991
        },
        "frameBase64": {
          "type": "string",
          "minLength": 1,
          "maxLength": 12000000
        },
        "frameEncoding": {
          "type": "string",
          "enum": [
            "rgba",
            "png"
          ]
        },
        "redactionVersion": {
          "type": "string",
          "minLength": 1,
          "maxLength": 120
        },
        "activity": {
          "type": "object",
          "properties": {
            "foregroundApplication": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "windowTitle": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "browserUrl": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "pointerActivity": {
              "type": "boolean"
            },
            "keyboardActivity": {
              "type": "boolean"
            }
          },
          "required": [
            "foregroundApplication",
            "windowTitle",
            "browserUrl",
            "pointerActivity",
            "keyboardActivity"
          ]
        }
      },
      "required": [
        "recordId",
        "capturedAt",
        "width",
        "height",
        "frameBase64",
        "redactionVersion",
        "activity"
      ]
    }
  },
  "required": [
    "promptVersion",
    "maxOutputTokens",
    "evidence"
  ]
}
```

</details>

### `POST /v1/apps/:appId/accounts/signup`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "email": "person@example.com",
  "password": "example-password-long-enough"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    },
    "password": {
      "type": "string",
      "minLength": 10,
      "maxLength": 1024
    }
  },
  "required": [
    "email",
    "password"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/accounts/resend`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "email": "person@example.com"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    }
  },
  "required": [
    "email"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/accounts/verify`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "email": "person@example.com",
  "code": "123456"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    },
    "code": {
      "type": "string",
      "pattern": "^\\d{6}$"
    }
  },
  "required": [
    "email",
    "code"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/accounts/recovery/start`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "email": "person@example.com"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    }
  },
  "required": [
    "email"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/accounts/recovery/verify`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "email": "person@example.com",
  "code": "123456"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    },
    "code": {
      "type": "string",
      "pattern": "^\\d{6}$"
    }
  },
  "required": [
    "email",
    "code"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/sessions/sign-in`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "email": "person@example.com",
  "password": "example-password-long-enough"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 254,
      "format": "email",
      "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
    },
    "password": {
      "type": "string",
      "minLength": 1,
      "maxLength": 1024
    }
  },
  "required": [
    "email",
    "password"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/sessions/current`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/sessions/revoke`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/workspaces/:workspaceId/text-decisions`

- Tenant, principal and conversation are derived from the authenticated workspace membership. A choice does not grant execution authority.

Example:

```json
{
  "requestId": "email-choice-1",
  "question": "Prepare email options?",
  "context": "Your existing email will stay as it is.",
  "mode": "yes-no",
  "recommendation": null,
  "createdAt": "2026-09-06T12:00:00Z",
  "expiresAt": "2026-09-06T12:15:00Z",
  "options": [
    {
      "id": "prepare",
      "label": "Prepare options",
      "consequence": "Compare mailbox providers.",
      "intent": "preference",
      "planHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "quote": null
    },
    {
      "id": "wait",
      "label": "Wait",
      "consequence": "Leave things unchanged.",
      "intent": "decline",
      "planHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
      "quote": null
    }
  ]
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "requestId": {
      "type": "string",
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
    },
    "question": {
      "type": "string",
      "minLength": 1,
      "maxLength": 300
    },
    "context": {
      "type": "string",
      "minLength": 1,
      "maxLength": 300
    },
    "mode": {
      "type": "string",
      "enum": [
        "numbered",
        "yes-no"
      ]
    },
    "options": {
      "minItems": 2,
      "maxItems": 4,
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "maxLength": 300
          },
          "consequence": {
            "type": "string",
            "minLength": 1,
            "maxLength": 300
          },
          "intent": {
            "type": "string",
            "enum": [
              "preference",
              "purchase",
              "decline"
            ]
          },
          "planHash": {
            "type": "string",
            "pattern": "^[a-f0-9]{64}$"
          },
          "quote": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 300
                  },
                  "product": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 300
                  },
                  "quantity": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 10000
                  },
                  "currency": {
                    "type": "string",
                    "enum": [
                      "USD",
                      "EUR",
                      "GBP",
                      "CAD",
                      "AUD"
                    ]
                  },
                  "totalDueNowMinor": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9007199254740991
                  },
                  "tax": {
                    "type": "string",
                    "enum": [
                      "included",
                      "not-applicable",
                      "unknown"
                    ]
                  },
                  "recurring": {
                    "anyOf": [
                      {
                        "type": "object",
                        "properties": {
                          "totalMinor": {
                            "type": "integer",
                            "minimum": 0,
                            "maximum": 9007199254740991
                          },
                          "interval": {
                            "type": "string",
                            "enum": [
                              "month",
                              "year"
                            ]
                          },
                          "startsAt": {
                            "type": "string",
                            "format": "date-time",
                            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
                          }
                        },
                        "required": [
                          "totalMinor",
                          "interval",
                          "startsAt"
                        ],
                        "additionalProperties": false
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "terms": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 300
                  },
                  "sourceUrl": {
                    "type": "string",
                    "format": "uri"
                  },
                  "retrievedAt": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
                  },
                  "expiresAt": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
                  }
                },
                "required": [
                  "provider",
                  "product",
                  "quantity",
                  "currency",
                  "totalDueNowMinor",
                  "tax",
                  "recurring",
                  "terms",
                  "sourceUrl",
                  "retrievedAt",
                  "expiresAt"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "id",
          "label",
          "consequence",
          "intent",
          "planHash",
          "quote"
        ],
        "additionalProperties": false
      }
    },
    "recommendation": {
      "anyOf": [
        {
          "type": "object",
          "properties": {
            "optionId": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
            },
            "reason": {
              "type": "string",
              "minLength": 1,
              "maxLength": 300
            }
          },
          "required": [
            "optionId",
            "reason"
          ],
          "additionalProperties": false
        },
        {
          "type": "null"
        }
      ]
    },
    "createdAt": {
      "type": "string",
      "format": "date-time",
      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
    },
    "expiresAt": {
      "type": "string",
      "format": "date-time",
      "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
    }
  },
  "required": [
    "requestId",
    "question",
    "context",
    "mode",
    "options",
    "recommendation",
    "createdAt",
    "expiresAt"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/workspaces/:workspaceId/text-decisions/:requestId/send`

- Requires an existing verified contact. Returns queue state, not handset delivery. No link token is returned to the project key.

Example:

```json
{
  "bindingHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "bindingHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$"
    }
  },
  "required": [
    "bindingHash"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/workspaces/:workspaceId/text-decisions/:requestId/cancel`

- Supply the bindingHash returned by create/read; this example hash is illustrative.

Example:

```json
{
  "bindingHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "bindingHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$"
    }
  },
  "required": [
    "bindingHash"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/workspaces`

- Workspace kind is set by the configured application; MUNKY OS is personal.

Example:

```json
{
  "displayName": "My work"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "displayName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    }
  },
  "required": [
    "displayName"
  ],
  "additionalProperties": false
}
```

</details>

### `PUT /v1/apps/:appId/workspaces/:workspaceId/policy`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "paused": true
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "paused": {
      "type": "boolean"
    }
  },
  "required": [
    "paused"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/workspaces/:workspaceId/devices`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "runtimeExternalId": "installation-1",
  "kind": "customer-edge",
  "platform": "macos",
  "runtimeVersion": "1.0.0",
  "protocolVersion": "attesko.edge.runtime.v1",
  "features": {
    "observations": false,
    "execution": "none",
    "observationAdapters": [],
    "executionAdapters": [],
    "uncertaintyChannels": []
  }
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "runtimeExternalId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200,
      "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
    },
    "kind": {
      "type": "string",
      "enum": [
        "attesko-desktop",
        "customer-edge",
        "server-edge"
      ]
    },
    "platform": {
      "type": "string",
      "enum": [
        "macos",
        "windows",
        "linux",
        "server"
      ]
    },
    "runtimeVersion": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "protocolVersion": {
      "type": "string",
      "const": "attesko.edge.runtime.v1"
    },
    "features": {
      "type": "object",
      "properties": {
        "observations": {
          "type": "boolean"
        },
        "execution": {
          "type": "string",
          "enum": [
            "none",
            "adapter"
          ]
        },
        "observationAdapters": {
          "default": [],
          "maxItems": 20,
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "native-event",
                  "browser-dom",
                  "os-accessibility",
                  "screen-capture"
                ]
              },
              "sourceSystems": {
                "default": [],
                "maxItems": 100,
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 100,
                  "pattern": "^[a-z][a-z0-9_.-]*$"
                }
              }
            },
            "required": [
              "kind"
            ]
          }
        },
        "executionAdapters": {
          "default": [],
          "maxItems": 20,
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "native-api",
                  "mcp",
                  "browser-dom",
                  "os-accessibility"
                ]
              },
              "capabilityIds": {
                "default": [],
                "maxItems": 200,
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 100,
                  "pattern": "^[a-z][a-z0-9_.-]*$"
                }
              },
              "attendance": {
                "type": "string",
                "enum": [
                  "attended"
                ]
              }
            },
            "required": [
              "kind"
            ]
          }
        },
        "uncertaintyChannels": {
          "default": [],
          "maxItems": 4,
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "local-notification",
              "sms",
              "email",
              "webhook"
            ]
          }
        }
      },
      "required": [
        "observations",
        "execution"
      ]
    }
  },
  "required": [
    "runtimeExternalId",
    "kind",
    "platform",
    "runtimeVersion",
    "protocolVersion",
    "features"
  ]
}
```

</details>

### `POST /v1/apps/:appId/workspaces/:workspaceId/phone-challenges`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "phone": "+15555550123",
  "consent": true
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "phone": {
      "type": "string",
      "pattern": "^\\+[1-9][0-9]{7,14}$"
    },
    "consent": {
      "type": "boolean",
      "const": true
    }
  },
  "required": [
    "phone",
    "consent"
  ],
  "additionalProperties": false
}
```

</details>

### `POST /v1/apps/:appId/workspaces/:workspaceId/phone-challenges/:challengeId/verify`

- Native client sends no project key. Authenticated operations require x-munky-application-session; credentials never belong in URLs.

Example:

```json
{
  "code": "123456"
}
```

<details><summary>JSON Schema</summary>

```json
{
  "type": "object",
  "properties": {
    "code": {
      "type": "string",
      "pattern": "^[0-9]{6}$"
    }
  },
  "required": [
    "code"
  ],
  "additionalProperties": false
}
```

</details>

## Event rejection codes

Per-item codes in a 200 ingest response. The request was well formed; the item was not stored.

- `privacy_rejected`
- `tenant_mismatch`
- `schema_invalid`
- `store_failed`

## Errors

| Code | Status | Meaning |
| --- | --- | --- |
| `application_account_owns_workspace` | 409 | Erase all workspaces owned by this account before deleting the account. |
| `application_credentials_invalid` | 401 | Email, password or verification code was not accepted for this application and environment. |
| `application_device_not_found` | 404 | The device is not visible to this workspace member. |
| `application_executor_admin_required` | 403 | Only a workspace owner or administrator can enroll an execution-capable runtime. |
| `application_invitation_invalid` | 404 | The invitation is expired, revoked, consumed or not addressed to this application account. |
| `application_mail_unavailable` | 503 | The application cannot deliver verification email. |
| `application_member_not_found` | 404 | The requested active workspace member was not found. |
| `application_membership_exists` | 409 | This account already has active membership. |
| `application_rate_limited` | 429 | This account or application has reached an authentication attempt or email limit. Do not retry immediately. |
| `application_session_invalid` | 401 | The application session is missing, expired, revoked or belongs to another application/environment. |
| `application_workspace_admin_required` | 403 | Workspace owner or administrator membership is required. |
| `application_workspace_not_found` | 404 | No active membership in this application workspace. |
| `application_workspace_owner_required` | 403 | Only the workspace owner may perform this operation. |
| `assertion_key_exists` | 409 | A key with that id is already registered for the environment. |
| `assertion_key_not_found` | 404 | No such delegated-principal key. |
| `assertion_key_store_not_configured` | 503 | This deployment has no delegated-principal key store. |
| `assignment_expired` | 409 | The Edge assignment lease has expired. |
| `assignment_not_found` | 404 | The assignment does not exist or is not bound to this runtime, tenant, and work item. |
| `attendance_not_declared` | 422 | An execution adapter did not state the attendance its rung has proved. Every built rung is attended: send `"attendance": "attended"` on each declared execution adapter. |
| `binding_hash_mismatch` | 409 | The supplied binding hash is not the exact current assignment or uncertainty binding. |
| `capability_not_declared` | 422 | An execution adapter names a capability that is absent or withdrawn. |
| `company_work_paused` | 403 | The connected business has paused new device work. Existing reports and independent verification remain available. This restriction is specific to the first-party business deployment. |
| `credential_invalid` | 401 | The key is absent, invalid, revoked, or for another environment. Do not retry unchanged. |
| `credentials_invalid` | 401 | The Product email and password pair did not authenticate. |
| `delivery_not_replayable` | 404 | No project-owned delivery with that event id is eligible for replay. |
| `desktop_gateway_unavailable` | 503 | Native gateway is unconfigured or its server credential is invalid or lacks required scopes. |
| `desktop_origin_forbidden` | 403 | This gateway accepts native HTTP requests without a browser Origin only. |
| `desktop_request_failed` | 502 | The gateway could not obtain a valid API response. |
| `device_credential_invalid` | 401 | The Product installation key is unknown, expired, revoked, or presented by another installation. Enrol the installation again. |
| `docs_not_deployed` | 503 | The deployment was built without its docs directory. |
| `forbidden` | 403 | The caller may not perform this operation on this resource. |
| `internal_error` | 500 | Munky's fault. Retry with the same idempotency key; the incident is logged with the request id. |
| `invalid_assertion_key` | 400 | The public key is not a valid Ed25519 SPKI PEM or the key id is malformed. |
| `invalid_environment` | 400 | The environment is not development, staging, or production. |
| `not_found` | 404 | No such resource in this project and environment. |
| `not_ready` | 503 | The database or required application schema is unavailable. |
| `observation_not_declared` | 403 | This runtime did not declare observation support when it registered. |
| `option_not_offered` | 422 | The resolution selects an option that the uncertainty record did not offer. |
| `payload_too_large` | 413 | The JSON body exceeds the route's limit: 2 MiB for events, 256 KiB elsewhere. |
| `personal_workspace_single_member` | 403 | Personal workspaces cannot invite other members. |
| `principal_assertion_invalid` | 401 | The delegated principal assertion does not verify for this decision, or, on a first-party Product decision route, was not signed under the authenticated installation's own key id. Sign the exact decision again on the machine that holds the key. |
| `product_enrollment_failed` | 500 | Product installation enrolment could not be completed. Retry; nothing was issued. |
| `registration_invalid` | 400 | First-party Product account creation was refused: the address or password did not meet the account rules. |
| `replay_unavailable` | 503 | This deployment cannot replay deliveries. |
| `resolution_conflict` | 409 | The uncertainty was already resolved with a different decision. |
| `runtime_not_deployed` | 503 | The local runtime release artifact is unavailable. |
| `runtime_not_found` | 404 | No such Edge runtime is visible to this caller. |
| `runtime_not_ready` | 409 | The runtime is not online or its latest heartbeat is no longer fresh. |
| `scope_required` | 403 | The key lacks the route's required scope. |
| `sdk_not_deployed` | 503 | The release is missing its SDK artifact; contact the operator. |
| `seat_principal_missing` | 409 | This Product seat has no principal yet, so it can decide nothing. Enrol the installation again. |
| `seat_revoked` | 403 | This Product seat or installation has been revoked. Nothing it presents will be accepted; sign the person out. |
| `source_not_declared` | 403 | The event source is not among this runtime's declared observation sources. |
| `stale_heartbeat` | 409 | The heartbeat predates the runtime's latest accepted health state. |
| `tenant_forbidden` | 403 | The tenant is outside this project or environment, or has been erased. Treat as an authorization fault. |
| `text_decision_conflict` | 409 | The request ID, conversation, binding or delivery state conflicts with stored state. Do not replace the prompt or blindly resend. |
| `text_decision_invalid` | 422 | The prompt, principal, tenant, quote or time window is invalid. |
| `text_decision_not_found` | 404 | No decision exists in this tenant and project environment. |
| `text_delivery_unavailable` | 503 | Verified text delivery is not enabled or its provider is not configured. |
| `timestamp_invalid` | 422 | The runtime timestamp is invalid or outside the accepted clock window. |
| `too_many_requests` | 429 | Wait for the Retry-After duration before retrying. |
| `uncertainty_conflict` | 409 | This assignment already has a different open uncertainty record. |
| `uncertainty_expired` | 409 | The uncertainty decision window has expired. |
| `validation_failed` | 422 | The request itself is malformed: not JSON, wrong shape, or a parameter outside its documented form. `detail` lists the fields. Correct it before retrying. |
| `verification_invalid` | 401 | The emailed Product verification code is wrong, already used, or expired. |
| `webhook_delete_unavailable` | 503 | This deployment cannot remove webhooks. |
| `work_lease_missing` | 409 | The core could not bind an Edge assignment because the work lease had no expiry. |

# Munky local runtime

Munky owns these services. An application such as Attesko embeds the released package and calls
its local HTTP contract; it does not copy the learning engine or build Munky server source.
The runtime package is separate from the cloud API and from either product installer.

## What the package contains

| File | Purpose |
| --- | --- |
| `website-task.mjs` | Local authenticated Namecheap/Railway inspection; no DNS mutation entry point |
| `core.mjs` | Local identity, learning, task authority, execution and independent receipts |
| `model-host.mjs` | Supervision of the separately packaged Ollama process |
| `company-relay.mjs` | Separate process for company networking and isolated browser work |
| `company-coordinator.mjs` | Company-owned coordination and selected work assignments |
| `drizzle/` | Local database baseline and migration journal |
| `node_modules/` | Pinned Playwright drivers; no browser profile or downloaded browser |
| `runtime-manifest.json` | SHA-256 inventory of packaged files |

The archive does not contain model weights, Node, Postgres binaries, customer data or credentials.
Applications must supply a compatible Node 22+ runtime and PostgreSQL toolchain. Model weights need
their own license, integrity and admission checks. Packaging a supervisor does not qualify a model.

## Start and stop the local core

Start a child process with a private stdin pipe:

```text
node core.mjs start --product attesko-os --data-dir PRIVATE_DIRECTORY \
  --migrations PACKAGE_DIRECTORY/drizzle --pg-bin POSTGRES_BIN_DIRECTORY \
  --parent-pid ACTUAL_PARENT_PID
```

Use `munky-os` for the personal product. Use separate data directories for the two products.
The first stdin line is JSON containing `transportToken`, a freshly generated 32–256 character
base64url token. Never pass it in argv, a URL, logs or browser storage. The core prints one
`local-core-ready` JSON record with its loopback `baseUrl`. It checks the actual parent PID and
stops when the parent exits. The private data directory is owned by the OS user and mode 0700.

Requests use `x-attesko-local-token`. This is an existing wire identifier; the runtime owner is
Munky. `/ready` and `/local/v1/stop` require that token. The CLI `status` and `stop` commands use the
same private stdin token and `--data-dir`. Do not expose the local port through Railway or a proxy.

The core starts private local Postgres, applies its schema, and persists its authority and receipts.
Starting a local core does not prove OS-level network denial. Product acceptance must separately
exercise the packaged app with networking denied and independently verify the requested effects.

## Network and model processes

`company-relay.mjs ACTUAL_PARENT_PID` receives a 64-character hexadecimal `transportToken` on its
first stdin line. It listens only on loopback and rejects browser Origin headers. The parent supplies
its resulting address and token as `networkRelay` when starting a core that needs company or browser
work. Network operations remain in this separate process; company transport checks the pinned
certificate and browser work uses explicitly configured authenticated sessions.

`model-host.mjs ACTUAL_PARENT_PID` starts `ollama/ollama` relative to its own package directory. It
supervises only its own process group. The product must configure model storage and network denial
before starting it. There is no remote-provider fallback in this helper.

## Company service

On a company-owned Mac, initialize a private service directory:

```sh
node company-coordinator.mjs init --directory PRIVATE_DIRECTORY --host PRIVATE_IP --port 9443
node company-coordinator.mjs serve --directory PRIVATE_DIRECTORY
```

Initialization writes private owner credentials and a pinned TLS identity. Keep `owner.json` on the
owner's machine. `pair` produces a short-lived pairing secret for one device. `devices`, `revoke`,
`pause`, `resume`, `assign`, `assignments`, `archived` and `cancel` manage selected company work.
Assignments still need exact device-side authority and an independent result check; sharing work
never grants unrestricted control of another Mac.

Packaged macOS login-service commands are `service-install`, `service-status` and `service-remove`.
Removing the login service preserves its data. The service is not a public cloud endpoint.

## Build and acceptance

Run `pnpm build:local-runtime` in MunkyAPI. The archive and checksum manifest are written to
`packages/server/assets/`. The build excludes source repositories, credentials, models and user
state. Products pin the archive SHA-256 and verify the internal file inventory before installation.

The runtime has real local file and controlled-browser regression evidence. General native app
control and representative task-model qualification remain unfinished. Failed research models are
not made eligible by a successful runtime build.

## Website inspector (0.17.0)

Run `node website-task.mjs` as a child process with one JSON object on private stdin:
`{action:"inspect", brief, credentials:{namecheap:{apiUser,apiKey,userName,clientIp},railway:{token,tokenType}}}`.
The brief uses the website-task schema from the integration guide. `tokenType` is `account` or
`project`; the Railway service link must include project, service and environment IDs. No ambient
CLI credentials, endpoint overrides or mutation actions are accepted. Keep credentials in native
secure storage and never in process arguments, logs, task memory or observation data.

Stdout returns `{ok:true,result}` with the advisory assessment and provider statuses, or
`{ok:false,error}` with a bounded code and nonzero exit status. Input is limited to 32 KiB, provider
responses to 2 MiB and each request to 15 seconds. The host must enforce an overall timeout.
The brief remains on the computer; only provider requests go to their respective services.
Missing custom domains or mail metadata block inspection. A ready review does not authorize a
write, prove a certificate is ready or establish that the site serves the expected content.
This entry point requires Node only; it does not start Postgres or a language model.

## Direct text decision storage (0.18.0)

Runtime 0.18.0 includes the additional direct-SMS choice storage migration. Earlier runtime archives
remain unchanged. SMS still requires the separately configured transport and verified contact;
this release does not add observation, a model, or DNS execution to the Mac website inspector.

## Experimental discovery diagnostic (development archive 0.19.4)

This is a local development entry point, not a deployed HTTP endpoint or qualified model release.
`node discover-task.mjs` accepts a single JSON object on private stdin, limited to 64 KiB:
`{owner: UUID, sourceId: UUID, capturedAt: ISO8601, text: string}`. Text is limited to 16,000 characters
and evidence must be within five minutes. The host obtains the text through its approved observer;
this CLI does not capture screens or authenticate an account. The host must bind `owner` to the
current account, isolate saved findings and discard results after stop or account change.

Inference requires a separately installed Ollama host at `127.0.0.1:11434` with `qwen3:4b`.
There is no cloud fallback; no model weights or model service are included in the archive.
The model tag is experimental and unqualified. Do not enable unattended execution from its output.
Stdout returns `{ok:true,result}` or a sanitized `{ok:false,error}` with nonzero exit status.
The result includes a tentative goal, literal evidence quote, domains, providers, unknowns,
source ID/hash/time, model identifier, `modelQualified:false` and `executionAuthorized:false`.
Grounding rejects invented literal targets; it does not prove complete semantic understanding.

For public documentation only, send
`{action:"research", owner: UUID, sourceId: UUID, providers:["namecheap","railway"]}`.
No arbitrary URL or private context is accepted. Results retain source URL, timestamp, response hash
when available, excerpts and explicit unavailable/incomplete states. Public excerpts cannot supply
account-specific DNS values or execution permission. Research has a 15-second per-source timeout;
inference has a 120-second timeout. Hosts must additionally bound child lifetime and output.

The controlled MUNKY OS test snapshots only its named Chrome prompt window and stops automatically.
Its simple sensitive-text rejection is not a general redaction guarantee. Saving a finding does not
train a model, create an executable procedure, send an offer or authorize any action.

### Discovery continuation (development 0.19.6)

Research supports the Namecheap article's actual heading and retries network failures and
408/429/500/502/503/504 responses at most three times with bounded backoff. Access denial and
irrelevant content remain explicit incomplete states. Hosts may persist completed sources and invoke
research only for unfinished providers, retaining the same owner and source ID.

`discover-task.mjs` additionally accepts `{action:"draft", goal, evidenceQuote, domains, providers}`.
This local-only operation returns `{draft, reason, executionAuthorized:false}`. It prepares only an
unambiguous paired root/www website draft involving Namecheap and Railway. Targets must occur in the
quote; deployment links remain blank. Other cases return a null draft or validation failure.
The host must bind this operation to the active owner's saved finding, reject changed results and
avoid overwriting user edits. It grants no account access or task authority.


## Development Chrome account bridge

Runtime 0.19.10 includes `browser-bridge/host.mjs`, `config.json`, and the Chrome extension.
MUNKY OS adds the launcher using its bundled Node executable. Clicking the Namecheap connection
registers `sh.munky.browser` in the user's Chrome NativeMessagingHosts directory and creates a
private, five-minute pending request under `~/Library/Application Support/sh.munky.os/browser-bridge`.
The native host accepts only the configured extension origin and exact message fields. Its local
result contains owner, nonce, provider, visible account label, page URL, observation time and
`executionAuthorized: false`. The desktop consumes the matching result and removes pending files.
This local transport is not a server identity assertion or a defense against compromised local code.

The unpacked development extension is inside the installed app at
`Contents/Resources/Runtime/browser-bridge/extension`. It requires Chrome nativeMessaging,
scripting, storage and tabs permissions, with host access limited to www.namecheap.com and
ap.www.namecheap.com. It does not request cookies or inspect password inputs. Installation must
be explicitly accepted by the user. It is not published in the Chrome Web Store.
Remove the extension in Chrome to revoke its browser access; remove
`~/Library/Application Support/Google/Chrome/NativeMessagingHosts/sh.munky.browser.json`
to unregister the native host. No Railway or Safari recognition is implemented yet.
