# @tapi-dev/sdk

Tapi's JavaScript/TypeScript SDK for **ServiceMaps, persistent browser sessions,
code workflows, explicit recording, and read-only live view**. Requires Node 20+.

A ServiceMap describes recorded entries, recognizable states and actions. A code
workflow decides what to do with them: branch on state or text, loop through
accounts, request input, pause for help, and reuse the same browser between jobs.
Studio is an authoring interface, not a prerequisite for running a workflow.

## Install and sign in

```sh
npm install @tapi-dev/sdk
npx tapi login
```

Use the SDK-managed sign-in rather than copying access tokens into configuration.
For a local app's backend:

```ts
import { TapiClient } from '@tapi-dev/sdk';
import { createTapiAuthProvider } from '@tapi-dev/sdk/auth';

const clientOptions = {
  baseUrl: 'https://your-tapi-api.example',
  tappId: 'RSA',
  getAuthToken: createTapiAuthProvider(),
};
const client = new TapiClient(clientOptions);
```

The provider renews saved sign-in where possible. The HTTP client refreshes once
on a 401 and retries that same request. Browser operations retain their operation
ID; an uncertain interaction is not replayed as a new action. If sign-in cannot be
renewed, run `tapi login`, then reconnect the app. Keep account credentials on the
backend, not in browser JavaScript, map documents, or workflow inputs.

## Local development

Keep `tappId` and `apiBaseUrl` in `tapi.config.json`. SDK-managed workspace/Runner
context owns enrollment, capacity identity and saved credentials; do not copy
these across several `.env` files.

```sh
tapi runner dev --workspace . --tapp RSA
tapi studio dev --workspace . --tapp RSA
```

Runner owns Chromium, persistent profiles, browser slots and capture. Studio edits
the map and presents live sessions. A local app can use the Runner without Studio.
Studio dev uses workspace source; restart the relevant process after an edit.
`tapi dev` starts the integrated Runner, Studio and workflow-host development loop.
Use the Runner's profile controls to create/manage profiles, then pass a saved
`profileRef` when preparing a workflow. Closing Studio does not close those sessions.

## Prepare a browser for a workflow

```ts
const map = await client.maps.get('your-map-id', 'your-pinned-revision');
const session = await client.sessions.prepareForWorkflow({
  capacityId: configuredCapacityId,
  map: map.ref,
  profileRef: savedProfileRef,
  entryId: 'entry_1',
  timeoutMs: 15_000,
  onProgress: operation => showStatus(operation.message),
});
```

Policy, not app-specific recovery code, chooses an idle matching session, opens its
recorded entry if new, or recovers an interrupted browser in its existing session
and slot. It preserves the selected profile, pins the map revision, and waits for
recognition. It refuses to steal active work, erase an unfinished recording, or
replace a browser that is still under manual control. Supply `sessionId` if you
need to select one existing session explicitly.

`client.sessions.list({ capacityId })` returns the session inventory for that
capacity. `attach(sessionId)` creates a handle without taking control or launching
Chromium. `session.info()` reads its current state. Taking control is explicit:
`await session.takeControl()`. `session.close()` closes the session and releases its
slot; `session.restart()` requests recovery through the existing lifecycle policy.
`open()` executes normally; `openRecording()` explicitly starts recording.

## Recognized pages and action completion (0.3.0)

Recognition waiting is owned by session policy, not workflow code. Version 0.3.0
removes `session.waitForKnownState()` and `RecognitionWaitOptions`. Workflow
preparation options are now named `SessionPreparationOptions`.

```ts
// prepareForWorkflow has already established a recognized page.
const page = session.currentPage;
if (page.is(map.states.s4)) {
  console.log(page.text('#message')); // present, missing, or unavailable
}
const next = await session.perform(map.action('s4', 's4_a0'), actionArguments);
// Input was delivered once and its resulting page was recognized.
```

The workflow host supplies `page` to `run({ session, page, ... })` after policy
has acquired control and recognized the starting page. Resuming a checkpoint
waits for one of its `resumeStateIds` before workflow code starts. Initial
recognition and action outcomes have a 15-second default deadline. Unknown or
ambiguous scans never become successful merely because the deadline expires.

`currentPage` reads the last accepted observation for this controller without
scanning. It throws if none is available. It is a snapshot, not a promise that
the browser can never change: server ownership, observation, source-state and
Runner target/document checks still guard every input. Workflows should reuse
the starting page and the page returned by each action, not rescan before each
account or after consulting application data.

`observe(map)` remains an explicit one-capture inspection tool. It is not a
readiness prerequisite or an automatic retry. `waitForInput()` waits for external
application data, not browser recognition.

Deploy the matching Railway workflow-readiness policy before running SDK 0.3.0
hosts. A host connected to an older backend fails before invoking workflow code
instead of silently returning to caller-managed recognition.

### Recorded transitions versus discovery

The server derives recognition context from the pinned map and the recorded action
actually executed. A saved edge from that source/action makes its destination(s)
eligible at **95% similarity without the control-change veto**. Initial recognition
uses **98% similarity without the veto** because there is no known current state
to compare a change against. Changes from a known state without a recorded edge
use **98% similarity with the veto**. A desired `until` state or
`stateIds` list does not create a recorded edge or lower its threshold. An action
without an edge does not qualify merely because its fallback is the source state.

Weights, recognition exclusions, content-region scoring and the 1% ambiguity
margin are unchanged. Incomplete scans and explicit identity conditions remain
blocking; target/document checks still run before input. Multiple plausible
states are not resolved by blindly preferring the expected destination.

Accepted observations retain their server-derived path context for the next
action pre-check. Read-only refreshes can carry it forward only in the same control
epoch, map revision and structurally unchanged document. New controls/regions,
a navigation, control transfer or new unrecorded input cannot inherit this relaxed
context. Initial matches also retain their context for unchanged-page checks;
failed scans do not erase a known-state-change baseline during retries. This is
recognition provenance, not a permanent session flag.

Inspect `page.raw.recognition.matching` for the actual `threshold`, `mode`,
`control_change_veto`, `recorded_path` and message. Every `candidate_scores` entry
also reports its own threshold and mode. Recognition deadlines and no-input-replay
behavior are unchanged. Workflows cannot override these server-owned matching
rules with SDK flags.

`PageObservation` exposes `recognition`, `stateId`, `controls`, `raw`, `is(state)`,
`text(selector)`, and `read(map.bindings.name)`. Live text and control values remain
available to workflow conditionals; changing their values does not automatically
define a new state. Captured content-region presence contributes to recognition,
separately from explicit distinguishing-text conditions.

For text omitted from a recognition scan, read the current DOM explicitly:

```ts
const reading = await session.readText('#main-content', { map });
console.log(reading.value, reading.captured_at);
```

`readText(selector, { map? })` reads rendered text from a top-document CSS target,
including open-shadow descendants. It requires this session's controller and
fences the request against the observed URL/document generation. It does not
click, type, wait for recognition, record an action, or add changing text to
recognition. An empty value means no readable text; a missing target/read failure
throws rather than falling back to an old observation. Reads are bounded to
100,000 characters. Input values and hidden content are excluded. The receipt
includes the source URL, document generation, selector, operation ID and capture
time. Capture time is when the text was read, not a guarantee that information
displayed by the website is up to date.

This method requires the matching Railway `read-text` operation and Runner
support; rebuild the local SDK and deploy/restart those components together.

Execute a recorded action with its revision-pinned reference and parameter schema:

```ts
const pageAfter = await session.perform(
  map.action(sourceStateId, recordedActionId),
  actionArguments,
  { until: { any: [{ state: expectedStateId }] }, timeoutMs: 15_000 },
);
```

Omit `until` to let policy wait for one of the action's recorded destinations.
For a recorded in-state form action without an outgoing edge, policy waits for
its source state again. Every successful `perform()` returns a recognized
post-input observation, including when `until` is omitted. It never returns the
cached pre-action page as proof of completion.

Explicit `until` conditions support states or scoped text with
`equals`/`contains` and optional `allowExisting`; text completion still requires a
recognized page for recorded actions. These conditions do not invent a recorded
edge or override recognition weights. Retries within the deadline capture only;
they never replay input. An input receipt remains the authority for delivery:
this does not add a selected-value DOM confirmation.

Studio demonstrations retain their separate Confirm Stable flow. Unrecorded
`interact()` calls retain their explicit outcome conditions and do not gain a
recorded-transition allowance merely because a caller supplied a desired state.

`session.interact({ kind, payload, inputValue? }, { map, until?, timeoutMs? })`
executes an explicitly supplied interaction without recording it into the map.
Use it for a deliberate recovery action, not automatic guessing on unknown pages.
`session.askForHelp(message)` stops workflow execution with a needs-attention result;
it is not itself a takeover UI. Apps must expose their intended manual-control path.

## ServiceMaps and recognition-only exclusions

```ts
const map = await client.maps.get(mapId, pinnedRevision);
const edits = client.maps.edit(map.ref)
  .ignoreControlsForRecognition('s5', [
    { selector: '#chkReinvest', documentPath: [], shadowPath: [] },
    { selector: '#lockBtn', documentPath: [], shadowPath: [] },
  ]);
const preview = await edits.preview();
const committed = await edits.commit();
```

**`ignoreControlsForRecognition` means ignore only when comparing states.** It does
not hide, disable or delete a control, remove its action, suppress its values, or
filter raw observations/live view. The matcher excludes the exact scoped targets
from both the saved and observed comparison for that state. Remaining identifying
controls must agree: an added, removed, or type-changed control rejects a candidate,
rather than being forgiven by its importance weight. Explicit identity conditions
are additional requirements, not shortcuts around the structural comparison.

Targets use selector + document path + shadow path, not an ephemeral control ID.
Omitted paths mean the top document outside shadow roots. The control may be absent
from a particular capture, which is precisely what recognition exclusions allow.
Do not exclude a control that distinguishes two meaningful workflow states. Rules
that conflict with explicit identity selectors/text are rejected.

Inspect rules at
`map.states.s5.recognition.ignoredControlsForRecognition`. Restore participation:

```ts
const restored = await client.maps.unignoreControlsForRecognition(
  committed.map.ref, 's5', [{ selector: '#lockBtn' }],
);
```

`client.maps.ignoreControlsForRecognition(ref, stateId, controls)` is the one-step
commit convenience; use an edit batch when you want preview or several atomic edits.
There is deliberately no ambiguously named `ignore()` API.

### Weighted content regions and recognition-only region exclusions

A region is one captured content entry identified by its complete scoped target.
Region identity does not include its current text, generated content ID, or screen
coordinates. Changing a price inside the same region does not reduce similarity.
Adding or removing a region does; a new banner participates even without a button.

The matcher compares non-ignored region presence using intersection / union.
Two empty, complete region sets have similarity 1. Region similarity contributes
25% of the score and control similarity contributes 75%. Discovery requires 98%;
verified recorded-transition destinations require 95% as described above. The
server adapter's `region_weight` constructor setting controls this share. Region
changes are weighted, not automatic reasons to reject a candidate.
For example, with identical controls, six shared regions and one new region score
about 96.4%, below the default threshold. One change among many regions may still
pass. Diagnostics expose `control_score`, `region_score`, `region_weight`,
`missing_regions`, and `extra_regions`.

Incomplete live content captures are unverified, not empty region sets. Saved
states without complete content evidence need recapture; they cannot silently
match by assuming content was absent. Explicit distinguishing-text conditions
remain the way to distinguish different messages at an unchanged location.

Use targets from actual captured content, including document and shadow paths.
The selectors below illustrate a changing quote-details region; they are not
built-in site rules:

```ts
const region = {
  selector: 'div.quote-details',
  documentPath: [],
  shadowPath: ['#quotePanel'],
};
const edits = client.maps.edit(map.ref)
  .ignoreRegionsForRecognition('s5', [region]);
const preview = await edits.preview();
const committed = await edits.commit();

const restored = await client.maps.unignoreRegionsForRecognition(
  committed.map.ref, 's5', [region],
);
```

`client.maps.ignoreRegionsForRecognition(ref, stateId, regions)` is also available
as a one-step commit. Both region operations support the same atomic edit batches,
revision checks, previews and idempotent commits as control exclusions. Inspect
`map.states.s5.recognition.ignoredRegionsForRecognition`; older maps expose an
empty list until a region exclusion is added.

Region exclusions remove only those exact content entries from both comparison
views. They do not hide text, remove observations, ignore an entire DOM subtree,
or ignore controls inside a region. Exclude controls separately when intended.
A region used by an explicit distinguishing identity condition cannot also be
ignored. Each state supports up to 100 control and 100 region exclusions. State
merges preserve both lists and reject conflicting identity rules.

No banner words or site-specific selectors are built into this policy. Confirm
Stable still decides when an unmatched observation becomes recorded knowledge;
recognition alone does not create a state or replay the action.

### Merge states without losing their actions

```ts
const edits = client.maps.edit(map.ref, { requestId: 'merge-s6-into-s5-v1' })
  .mergeStates({
    keep: 's5',
    remove: 's6',
    convertTransitionsToFormActions: [{ stateId: 's5', actionId: 's5_a0' }],
  })
  .ignoreControlsForRecognition('s5', [
    { selector: '#chkReinvest' },
    { selector: '#lockBtn' },
  ]);

const preview = await edits.preview();
// Present preview.serviceMap and preview.remap before committing if desired.
const result = await edits.commit();
const moved = result.remap.actions['s6/s6_a1'];
if (!moved) throw new Error('The Review action was not preserved');
const review = result.map.action(moved.stateId, moved.actionId);
```

The kept state retains its ID, captured baseline, name, board position and preview.
Moved actions receive new IDs; captured interaction evidence, parameter schemas,
observation bindings and outgoing transitions follow them. Source recognition
exclusions are retained. Every internal transition must explicitly become a form
action. An action that also transitions outside the merged pair cannot be converted
silently. Duplicate resulting edges are rejected rather than discarded.

In this example Buy stays a form action in S5, S6's quantity and Review actions move
to S5, Review still reaches S7, and S6 disappears. Use the returned action mappings;
do not guess new IDs or renumber later states.

Other typed batch operations:

- `redirectTransition({ sourceStateId, actionId, targetStateId }, newTargetStateId)`.
- Entry edges use `{ entryId, targetStateId }` instead of a source-state action.
- `removeTransition(edge)` removes the exact edge and its source action.
- `removeState(stateId)` removes an isolated state; connected states must first be
  explicitly rewired/cleared or merged.
- `unignoreControlsForRecognition(stateId, controls)` restores control recognition.
- `ignoreRegionsForRecognition(stateId, regions)` excludes exact content regions.
- `unignoreRegionsForRecognition(stateId, regions)` restores region recognition.

Preview and commit use the same domain edit engine. Preview does not publish a new
revision; its proposed action references are not executable. The batch is sealed by
its first preview/commit. Commit applies 1-100 edits atomically under the expected
revision. A concurrent edit rejects the whole batch. The same `requestId` and payload
can recover the original result after a lost response; no new map edit is performed.
A reused request ID with different changes is rejected.

Existing jobs keep their immutable pinned revision. Update the workflow's map
reference after commit, and call `prepareForWorkflow` to refresh an idle session
against that revision. Do not rewrite a running job's map or discard its browser.
Map authoring needs `map:write`; a running workflow cannot edit its pinned map.

### Other map operations

`maps.create(name, medium)`, `list()`, `get(id, revision?)`, `delete(ref)` and
`plan(ref, fromState, toState, allowedActions)` support map lifecycle and scoped
navigation. The existing low-level `edit(mapId, expectedRevision, changes)` remains
for entry edits, observation bindings, identity/name edits and action parameter
schemas. Prefer the typed batch operations above for recognition and graph surgery.
`defineMap(document)` wraps a local map contract; `tapi maps pull MAP_ID` generates a
compact pinned contract in `tapi/maps/` rather than copying browser credentials.

## Explicit recording and manual control

Execution is not recording. Use `beginEntryRecording(entry)`,
`beginActionRecording(kind, payload, inputValue?)` (or `demonstrate()`), or
`beginBaselineRecording()` when teaching. `confirmStable()` confirms that explicit
recording and persists its state/action evidence. `discardRecording` requires its
expected version and recording identity; workflow preparation never erases it.

`session.desktop('open' | 'return' | 'extend' | 'status' | 'confirm_baseline')`
uses the existing desktop-control policy. Takeover, desktop return and a fresh
baseline are separate from resuming a workflow. Studio presentation is available
through `client.studio.windows()` and `client.studio.reveal(windowId, sessionId, stateId?)`.

## Code workflows and late input

Use the bundle-safe authoring entrypoint in workflow files. Keep filesystem access,
authentication, hosting and SDK CLI imports in the app backend, not the workflow:

```ts
import { defineWorkflow } from '@tapi-dev/sdk/workflow';

export default defineWorkflow({
  name: 'read-current-state',
  maps: [{ mapId: 'your-map-id', revision: 'your-pinned-revision' }],
  inputs: { type: 'object', properties: {}, additionalProperties: false },
  result: {
    type: 'object', properties: { stateId: { type: 'string' } },
    required: ['stateId'], additionalProperties: false,
  },
  async run({ page, note }) {
    await note(`Recognized ${page.stateId}`);
    return { stateId: page.stateId };
  },
});
```

The run context includes `session`, `page`, `maps`, `input`, `note`, `waitForInput`,
`resume` and `checkpoint`. `page` is the policy-recognized starting observation.
A workflow can pause without closing Chromium. Types are inferred from the schema;
do not supply an unrelated `<string>` or other response-type assertion:

```ts
const code = await waitForInput({
  key: 'securityCode', label: 'Verification code', secret: true,
  schema: { type: 'string', minLength: 4, maxLength: 10 },
  timeoutMs: 300_000,
}); // code: string
```

### Shared input contracts (0.2.10)

Define object replies once in a module imported by both the workflow and app.
The authoring entrypoint is bundle-safe and does not import filesystem or auth code:

```ts
// input-contracts.ts
import { defineInputContract } from '@tapi-dev/sdk/workflow';
export const inventoryReply = defineInputContract('inventory', {
  type: 'object',
  properties: {
    accounts: {
      type: 'array', maxItems: 100,
      items: {
        type: 'object',
        properties: { accountId: { type: 'string', minLength: 1 } },
        required: ['accountId'], additionalProperties: false,
      },
    },
  },
  required: ['accounts'], additionalProperties: false,
});
```

```ts
// Inside the workflow; import inventoryReply from the shared module.
const inventory = await waitForInput({
  key: 'inventory', contract: inventoryReply, timeoutMs: 600_000,
});
inventory.accounts.forEach(account => console.log(account.accountId));
```

```ts
// In the app backend; use the same inventoryReply.
const job = await client.workflows.get(jobId);
const waiting = job.waiting_input;
await client.workflows.provideInput(jobId, waiting.key, {
  accounts: [{ accountId: 'broker:318' }],
}, {
  attempt: waiting.attempt, waitingInput: waiting, contract: inventoryReply,
  submissionId: crypto.randomUUID(),
});
```

The contract drives the inferred reply type on both APIs. No unrelated generic
can assert a different result type. Contract definitions are cloned and frozen.
`provideInput` validates against the shared contract and the persisted request
schema before POST. Passing the already-read `waitingInput` avoids an extra GET;
without it, the SDK reads the current job to obtain its schema and attempt.
The policy remains authoritative for ownership, expiration and stale attempts.
Workflow requests are validated before IPC, and returned values are validated
again before the workflow resumes. JavaScript callers receive the same runtime
checks. Errors name the request and field, such as
`inventory.accounts[0].accountId: required field is missing`, without printing
supplied values. Do not log secret input values.

Object schemas are closed by default, matching Tapi policy. A bare
`{ type: 'object' }` is rejected locally as an incomplete contract: declare its
`properties`, use `properties: {}` for an intentionally empty object, or explicitly
set `additionalProperties: true` for free-form JSON. Arrays require an `items`
schema. Input timeouts must be 1,000-600,000 milliseconds. The default is 300,000.
`defineInputContract`, `validateWorkflowInput`, `WorkflowInputValidationError`
and the schema-inference types are available from the root and workflow exports.
Existing raw-schema calls remain available and infer their response type directly.

Prepare and host code from a Node backend:

```ts
import { bundleWorkflow, serveWorkflowJobs } from '@tapi-dev/sdk';
const artifact = await bundleWorkflow('./workflow.mjs', process.cwd());
const version = await client.workflows.prepare(artifact.source, artifact.manifest);
const job = await client.workflows.run({
  sessionId: session.id, versionId: version.version_id, inputs: {},
});
await serveWorkflowJobs(clientOptions, { jobId: job.job_id, once: true });
```

`workflows.publish(source, manifest)` publishes a workflow version, not an npm
package. Other operations include `list()`, `get(jobId)`, `grant(scopes, sessionIds,
seconds)` and `revoke(grantId)`. A browser session can survive several jobs; workflow
completion does not mean its slot is available. Close the session explicitly to
release it. `runners.list()` is an inventory facade requiring server support, not a
readiness check; use the configured capacity and session preparation policy.

## Embed a read-only live view

```ts
const connection = await session.liveView.connect({
  onFrame: ({ data, metadata }) => renderJpeg(data, metadata),
  onStatus: snapshot => renderStatus(snapshot),
  onConnectionState: state => showConnection(state),
  onDisconnect: error => showConnectionError(error),
});
// When the embedding component is disposed:
connection.close();
```

Frames, surface metadata and Runner status are available; pointer and keyboard
input are not. This does not acquire control, create a session or teach the map.
Reconnect renews access from the existing session. Environments without a global
WebSocket must pass a WebSocket constructor to `TapiClient`. For browser apps, keep
owner credentials server-side and expose only appropriate short-lived read-only
access or a frame relay. The stock-lookup sample in `__RSA` uses this separation.

## CLI and agent access

```sh
tapi workflow dev ./workflow.mjs --session SESSION_ID --input '{"symbol":"TSLA"}'
tapi workflow publish ./workflow.mjs
tapi workflow host
tapi maps pull MAP_ID
tapi sessions list
tapi mcp --control --author --run --publish
```

MCP is a stdio adapter to the same policy boundaries, not another browser/session
manager. Enable only the capabilities an agent needs; authoring, browser control,
workflow execution and publication are separate permissions. The SDK's typed edit
batch is the authoring interface for the new recognition/merge operations.

## SDK development and npm release

The published package contains generated `dist` files and this README. Workspace
apps can use a local file dependency while developing; build the SDK after edits.
Publishing is only required for consumers that install from npm.

```sh
npm run build
npm login --auth-type=web
npm publish --access public
```

The default publish lifecycle runs the package's tests and build. If the release
operator intentionally skips lifecycle scripts, build first and use
`npm publish --ignore-scripts --access public`; that does not run the tests.
Increment the package version before publication. Npm authentication is separate
from `tapi login`; complete npm's browser authorization when prompted.

The current root client is organized around `maps`, `sessions`, `workflows`,
`studio` and the Runner inventory facade. Older service/runbook/runtime examples
are not the current public workflow API and should not be copied into new apps.

## Developer takeover and saved workflow progress

Opt in with `allowDeveloperTakeover: true` on a workflow run or browser preparation. Use `tapi.takeovers` to present, accept, return, resume, or cancel a same-session Studio handoff. Workflow code owns safe restart boundaries through `checkpoint()` and receives saved progress as `resume`. See [Developer takeover](DEVELOPER_TAKEOVER.md) for the complete contract and RSA example.
