npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tapi-dev/sdk

v0.3.14

Published

`tapi mcp --debug` adds `server_runtime`, `server_trace`, `server_backlog`, `server_capture_start`, `server_capture_read`, and `server_capture_stop`. These use the signed-in SDK owner's existing server-admin permission, not a workflow's delegated browser

Readme

@tapi-dev/sdk

Live Railway server diagnostics (operator MCP)

tapi mcp --debug adds server_runtime, server_trace, server_backlog, server_capture_start, server_capture_read, and server_capture_stop. These use the signed-in SDK owner's existing server-admin permission, not a workflow's delegated browser grant. Ordinary users/jobs cannot inspect the server.

Start a capture with { "sessionId": "...", "seconds": 20 }, perform one safe interaction with the existing MCP tools, then read its captureId. Use the operation ID with server_trace to inspect actual queue, database, transport, packing and response-serialization spans. session_operation_timing also includes HTTP phase timings and Railway/server trace IDs. Inspection never replays input.

Stack sampling is process-wide and includes idle/waiting Python threads; it is not per-operation CPU attribution and does not inspect locals or native stacks. Captures are limited to 60 seconds, one at a time, four retained captures, and ten-minute retention. Timelines hold at most 4096 completed spans per replica. Responses identify the replica; missing evidence after restart/eviction is not proof that no work occurred. Railway's native trace store provides exported history (railway trace list/get, current Railway CLI required).

The server uses standard OTEL_* settings for timed application spans. Enable Railway tracing and deploy to receive its exporter settings; explicitly configured OTLP endpoints override Railway's destination. No broker contents, tokens, SQL parameters, frame locals, or expression evaluation are exposed by these tools.

Application runs own their cleanup

Use a managed run for application workflows. Keep its opaque snapshot in your application store; do not implement separate stop-job, take-control, and close-browser buttons or cleanup retries.

const run = client.workflows.attachRun(savedRun || { id: crypto.randomUUID() }, {
  onChange: snapshot => saveRun(snapshot),
  signal: applicationShutdown.signal,
});
run.subscribe(status => renderRun(status));
await run.prepare({ capacityId, map: map.ref, entryId: "start" });
const job = await run.start({ versionId, inputs });
// Host/observe `job` using serveWorkflowJobs as usual.
await run.end();

end() is idempotent and reports progress immediately. Railway records the end intent, fences new preparation/job admission, revokes the run's job grants, and closes a browser created by this run. A borrowed browser is retained. Another run's active browser is never implicitly closed. Completion means resource cleanup is confirmed, not just that the HTTP request was accepted. The SDK reconnects and follows the durable run across individual close attempts.

Restore with attachRun(savedRun) and call end() again when its snapshot has endRequested; this never restarts workflow input. Aborting local observation does not retract an end request already accepted by Railway. Startup can finish after an end request, but cannot launch a new managed workflow; Railway cleans up the late browser. Closing a read-only viewer does not end its run. Ending a run does not undo an order or other action already submitted externally.

This API requires the managed-run Railway policy deployed with this SDK source. Raw session APIs remain available for infrastructure and advanced integrations.

Read dropdown choices without recording a click

session.discoverOptions(controlId) reads the current dropdown, including custom menus whose options are created only when opened. It uses the session's existing Runner and exclusive input lock. It does not select a value, recognize a new state, create a transition, or replace a pending Studio recording.

const page = session.currentPage;
const dropdown = page.controls.find(control => control.choiceStatus === "unavailable");
if (!dropdown) throw new Error("Choose a dropdown from the current page");
const choices = await session.discoverOptions(dropdown.controlId);
// Show choices.options in your UI. Wait for the user's choice:
async function choose(optionId: string) {
  return session.selectDiscoveredOption(choices, optionId);
}

Options include id, label, value, disabled, and selected. source distinguishes DOM from OCR. OCR may only discover visible rows; complete: false means the list must not be treated as exhaustive. OCR reports unknown disabled/selected state as null, not a guessed boolean.

Discovery leaves an already-open popup open. If it opens the popup, it restores the original closed state, focus, and scroll position before succeeding. Navigation or unconfirmed restoration fails explicitly and never selects a fallback. Discovery evidence belongs to one session, control epoch, and document; it is not reusable after navigation, browser restart, or transfer of control. selectDiscoveredOption performs a normal workflow interaction and returns its resulting page. Studio uses its normal action-recording path for the later selection, not for discovery itself.

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

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:

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.

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.

Development Tapps in the same source workspace, signed-in account and API server share one Runner and one capacity assignment. --tapp selects Studio's project, not another Runner. Each map still owns its saved browser profile. Per-Tapp Studio launch manifests are separate, so two Studios can safely use different UI ports. The first shared launch adopts the oldest matching existing enrollment in place; it preserves that Runner's capacity identity and profiles. Other legacy Runners and their open browsers are not forcibly closed or migrated. Close those sessions explicitly before retiring their old enrollment; their profiles are not copied automatically. Concurrent SDK launches serialize enrollment and startup locally.

Numeric identities for discovered options

Use the same numeric matching policy for discovery, application inventory, and selection instead of writing a broker-specific account-label parser:

import { numericOptionIdentifier, findNumericOption } from "@tapi-dev/sdk";

const choices = await session.discoverOptions(accountControlId, {
  optionMatching: { mode: "numeric_identifier" },
});

for (const option of choices.options) {
  // Original labels, IDs, values, and disabled flags are retained.
  console.log(option.id, option.label, option.numericIdentifier);
}

const choice = findNumericOption(choices.options, requestedAccountLabel);
// Selection inherits numeric_identifier from this discovery result.
const nextPage = await session.selectDiscoveredOption(choices, choice.id, {
  map, until: map.until("s1"),
});

// The same extraction is available for an observed selected-control label.
const identifier = numericOptionIdentifier(selectedControl.name);

Numeric identifiers are the single ASCII digit group in an option label. Names, letter prefixes, and masking characters are ignored; leading zeros are retained. Multiple digit groups or duplicate identifiers reject the catalogue rather than guessing. Numeric discovery also requires a complete catalogue. Entries with no digits remain in the result with numericIdentifier: null; the application can explicitly exclude them but should not describe them as identified accounts.

These helpers identify canonical catalogue options; findNumericOption still uses exact identifier equality. During OCR selection, the complete expected identifier may occur inside a longer digit sequence in an observed option row. For example, OCR reading a letter prefix as an extra digit does not invalidate the account's unchanged digits. The row must identify only one catalogue option, and only one row may match; separate numeric groups are never concatenated.

identifyNumericOptions(options) exposes the same annotation and uniqueness checks for an existing list; it does not establish discovery completeness. These helpers are available in normal SDK imports and bundled workflows. No helper sends browser input. Applications still own their ledger keys, display formatting, and eligibility rules. Existing discoveries without optionMatching retain their previous behavior. Studio dev uses workspace source; restart the relevant process after an edit. tapi dev starts the integrated Runner, Studio and workflow-host development loop. Each ServiceMap automatically owns a saved browser profile on its enrolled Runner. No profile creation or selection is required. Studio and SDK workflows resolve the same saved browser for that map. Closing Studio does not close those sessions.

Prepare a browser for a workflow

const map = await client.maps.get('your-map-id', 'your-pinned-revision');
const session = await client.sessions.prepareForWorkflow({
  capacityId: configuredCapacityId,
  map: map.ref,
  profileMode: "saved", // Default; this map automatically owns its saved browser.
  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 map-owned profile, pins the map revision, and captures a current document. Operations wait for their own required controls and text. 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.

Saved browser identity is scoped to the enrolled capacity owner, Tapp and stable ServiceMap ID, not the map name, URL or revision. Different maps get different profiles. An occupied saved browser is never cloned or silently replaced.

Use profileMode: "temporary" with open, openRecording or prepareForWorkflow for a fresh session-owned profile. Its data lasts for that session, including developer takeover, and is deleted when the session closes. Preparing another temporary browser creates a new profile; pass sessionId to explicitly reuse an existing temporary session. The saved profile is untouched. Modes cannot be changed inside an already-open browser. profileRef is no longer a launch/preparation option. Existing saved data can be adopted by the Runner without copying or deleting its directory; missing saved data is an error, not permission to silently replace it.

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.

Playback: required controls and text, not whole-page matching

Startup establishes ownership and a current document. It does not require a known state, and checkpoint state labels do not silently add a startup match gate. Deploy the matching Railway policy before using this SDK source: the workflow host requires playback_policy: 1 before invoking workflow code.

A recorded action's state ID is its catalogue address, not a demand that the page resemble its entire scan. Before input, shared policy waits up to 15 seconds for its uniquely resolved, visible, enabled control. Controller/job authority, pinned revisions, document, viewport and target fences are retained. Unrelated controls, activity rows, prices and generated content do not block it.

const page = await session.perform(map.action("s4", "s4_a0"), {}, {
  until: { any: [
    { name: "review", all: [
      { control: { selector: "#place-order", enabled: true } },
      { text: { selector: "#order-summary" } },
    ] },
    { name: "rejected", all: [
      { text: { selector: "#order-error", contains: "cannot be placed" } },
    ] },
  ] },
});
if (page.outcomes.includes("rejected")) console.log(page.text("#order-error"));

Each alternative has a unique name and a nonempty all list:

  • { control: { action: { stateId, actionId }, enabled: true } } resolves a demonstrated control.
  • { control: { selector, enabled, checked, equals, contains } } requires one live control.
  • { text: { selector, equals, contains } } requires one nonempty content region.
  • { binding: "receipt" } requires a declared map observation binding.
  • map.needs("s4") expands only demonstrated controls and declared reads for that catalogue state.

Use a target with explicit documentPath/shadowPath instead of selector for scoped requirements. Omitted paths mean the top document, not any frame. map.text("s6", "#order-summary") obtains the recorded scoped target without freezing its volatile value. present: false checks absence; text absence requires a complete capture. allowExisting: false requires change from the before-observation.

map.until("s6", "s7") is shorthand for named recorded requirement alternatives, NOT state-similarity matching. A state with no demonstrated targets or declared reads cannot vacuously satisfy it. Multiple satisfied alternatives are returned in page.outcomes; policy does not invent a winning business decision.

Without until, perform uses saved action outcomes, if present. Otherwise it returns a fresh document after input delivery. Delivery is not business success: workflows must still validate accounts, order terms, rejection text and receipts.

Reads and standalone waits

const quote = await session.readText("#quote", { map, timeoutMs: 15_000 });
const notice = await session.readText("#notice", { map, optional: true });
const receipt = await session.read("receipt", map);
const ready = await session.waitFor(map.until("s4"), { map });
if (ready.readyFor("s4")) console.log("The required controls/reads are present");

readText reads the top document and open-shadow descendants with URL/document fences, a 15-second default deadline, and optional equals/contains conditions. Optional reads can return a missing value; read failures remain errors rather than being misreported as absence. The Runner excludes input values and hidden content. session.read waits for a saved binding and returns its current snapshot value.

Use captured text regions when a meaningful value spans several child elements:

const anchors = page.textRegions.filter(region => region.text === "$"
  && region.target.kind === "web_dom" && !region.target.documentPath?.length);
if (anchors.length !== 1) throw new Error("Choose an unambiguous quote anchor");
const quote = await session.readTextRegion(anchors[0], { container: "parent", map });
console.log(quote.value, quote.region);

container is self by default, or the actual immediate DOM parent. It does not truncate selectors, choose the first match, cross a frame/shadow-root boundary, or expand to the page body. The application chooses the semantic anchor; policy binds its contentId to the supplied observation and checks document identity. The adapter resolves that exact element, checks its frame execution context, then reads the requested container. Parent reads also require the captured anchor text to remain unchanged. Returned region evidence identifies the source observation, target, relationship and frame. Navigation or an ambiguous/missing anchor fails without rebinding to another document. A parent read does not require clickable geometry and sends no input. Existing readText(selector) remains available.

MCP exposes the same operation as session_read_text_region with sessionId, observationId, contentId and optional container. Update Railway and Runner alongside the SDK before using relative reads; older runtimes fail closed.

currentPage and observe(map) can return unfamiliar pages. page.text and page.read are synchronous snapshot helpers: they cannot infer future reads or wait for them. Use asynchronous reads or explicit outcome requirements when readiness matters. page.is remains a recording-recognition helper. Playback routing uses readyFor, outcomes and the actual workflow values.

Missing, disabled or ambiguous required controls produce bounded-wait diagnostics in page.raw.playback or the operation result. Waiting repeats captures/reads, never delivered input. Interrupted or uncertain dispatches are not retried. Popup retry still requires explicit permission and proof that input was not sent.

Save requirements for Studio and other SDK clients

const edited = await client.maps.edit(map.ref)
  .setPlaybackRequirements("s6", [
    { control: { selector: "#place-order", enabled: true } },
    map.text("s6", "#order-summary"),
  ])
  .setActionOutcomes("s5", "s5_a0", map.until("s6", "s7"))
  .commit();
// Pin edited.map.ref in the next workflow version.

Requirements live in map revisions, not UI code. clearPlaybackRequirements restores demonstrated-control/read defaults. clearActionOutcomes removes an explicit action completion contract. Normal reads do not silently mutate a pinned map.

Studio reuses a requested action only when its recorded identity and input semantics are unambiguous. Existing paths use this same policy and finish without another Stable click or duplicate state when one destination's requirements are present. A missing/ambiguous outcome retains delivered input and asks for Stable; it never clicks again. New paths, entry recordings and manual baselines keep the recording matcher. Whole-page weights and exclusions are recording tools, not playback gates.

ServiceMaps and recognition-only exclusions

Workflow-owned popup dismissals

Declare a permitted dismissal once in defineWorkflow, not inside individual state actions. Rules are part of the immutable workflow version:

popupRules: [{
  id: 'feedback-survey',
  mapId: serviceMap.mapId,
  hostSuffix: 'ally.com',
  buttonNames: ['Share Feedback', 'Not Right Now'],
  dismissButton: 'Not Right Now',
}],

Policy uses existing workflow startup/outcome scans and admitted action evidence. All named buttons must occur uniquely within the same document/shadow scope on the specified host or a subdomain. It dismisses the popup before matching the underlying page, then continues the original expected-state check and deadline. The rule does not require a particular state, record a new state, lower matching thresholds, add background polling, or silently restore a previously matched page. At most one dismissal is attempted per browser operation. Ambiguous or recurring popups are not repeatedly clicked. Studio and ordinary observe() calls never execute these rules; standalone session preparation before a workflow is claimed also remains governed by its explicit map-owned sign-in path.

If a popup arrives after observation and blocks a click, opt a non-submission action into session.perform(action, args, { retryIfNotSent: true }) to permit one new dispatch after dismissal and fresh source-state validation. The default is false. Only a structured Runner receipt proving that no click was sent allows this path. Delivered or uncertain actions are never repeated; do not opt order submission into this recovery. Popup input intent is persisted before dispatch, and interrupted popup input is not reconciled as a harmless page read.

Scoped recognition exclusions

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. By default, the matcher excludes exact scoped targets from both the saved and observed comparison for that state. Remaining controls contribute their importance weights to similarity: additions, removals, and type changes can lower the score but do not independently reject a candidate. Explicit identity conditions remain additional requirements.

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:

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.

For a changing family of nonessential controls, opt into a literal captured selector prefix instead of listing today's values:

const recentSymbols = {
  selector: 'button[aria-label="recent symbol ',
  selectorMatch: 'prefix' as const,
  documentPath: [],
  shadowPath: [],
};
await client.maps.edit(map.ref)
  .ignoreControlsForRecognition('s4', [recentSymbols])
  .commit();

This excludes recent-symbol shortcuts for any ticker on both sides of the comparison, including tickers absent from the saved capture. Prefix matching is case-sensitive, preserves literal whitespace, and still requires the exact document/shadow scope. It compares stored selector strings; it does not execute CSS, support regex/globs, or ignore a DOM container's contents automatically. Use a specific prefix, not a general tag or ID prefix. Explicit identity selectors covered by the prefix are rejected, just like exact-rule conflicts. Actions and raw observations remain unchanged. Remove the rule using unignoreControlsForRecognition with the same prefix, mode and scope. Omitting selectorMatch, or setting it to exact, retains existing exact-target behavior.

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 recording score and control similarity contributes 75%. Recording discovery requires 98%. These percentages never gate SDK playback or existing-path readiness. 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:

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.

By default, region exclusions remove only exact content entries from both comparison views. An explicitly ignored numeric region also follows a unique same-scope, same-kind selector/text template when only its displayed numeric value changes, matching the existing numeric-control ignore behavior. Ambiguous numeric regions and nonignored regions are not normalized; original observations remain intact. Region targets can also opt into selectorMatch: 'prefix' with the same scoped, literal-selector semantics described above. 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

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:

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 a current document, not necessarily a recognized state. A workflow can pause without closing Chromium. Types are inferred from the schema; do not supply an unrelated <string> or other response-type assertion:

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:

// 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,
});
// 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));
// 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.

App-handled input requests (0.3.1)

Use payload when an app needs structured details to answer a workflow request. Do not send those details through note() and then search activity messages: activity delivery is independent of input-request delivery.

const inventory = await waitForInput({
  key: 'inventory', contract: inventoryReply,
  payload: { protocol: 'my-app.v1', operation: 'inventory', runId: input.runId },
  timeoutMs: 600_000,
});

// In the authenticated app backend:
const job = await client.workflows.get(jobId);
const waiting = job.waiting_input;
if (job.status === 'waiting_for_input' && waiting?.payload?.protocol === 'my-app.v1') {
  // Validate the operation and bind runId to your app's saved job/instructions.
  // Deduplicate side effects by job_id + key + attempt, saving the reply atomically.
  const reply = await handleAppRequest(job, waiting);
  await client.workflows.provideInput(jobId, waiting.key, reply, {
    attempt: waiting.attempt, waitingInput: waiting, contract: inventoryReply,
    submissionId: stableSubmissionId(jobId, waiting.key, waiting.attempt),
  });
}

handleAppRequest and stableSubmissionId above are app-owned functions. The payload is a plain, finite JSON object of at most 64 KiB, cloned before IPC. It is persisted atomically with the key, schema, attempt and waiting status. It is not secret: anyone authorized to read the job can read the payload. secret: true describes the requested response, not payload encryption or hiding. Keep passwords and verification-code values out of payloads and activity notes.

Policy rejects reuse of a request ID with different contents. Retrying the same request returns the same attempt, even if its response has already arrived. The host consumes only the response for that request ID and attempt. Existing authentication, session ownership, expiration and response-schema checks remain.

job.last_input_request retains the latest complete request after interruption or response consumption, without the supplied response value. It is recovery evidence, not an instruction to rerun work. Apps may reconcile a saved receipt against an existing reservation; they must not replay browser actions or assume that a receipt proves an order filled. A later request replaces this snapshot.

Deploy the matching Railway policy before running workflows with payloads. The SDK rejects an acknowledgement that omitted or changed the request payload.

Prepare and host code from a Node backend:

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,
  onStatus: status => showHostStatus(status.message),
  onJob: async job => updateApp(job),
});

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

Browser apps can use the browser-safe @tapi-dev/sdk/live-view entry point:

import { createReadonlyLiveViewer, ReadonlyLiveViewUnavailableError } from '@tapi-dev/sdk/live-view';

const viewer = createReadonlyLiveViewer({
  image: document.querySelector<HTMLImageElement>('#live-view')!,
  async resolveAccess(sessionId, { signal }) {
    const response = await fetch('/api/live-access?sessionId=' + encodeURIComponent(sessionId), { signal });
    const value = await response.json();
    if (!response.ok) throw new ReadonlyLiveViewUnavailableError(value.error, value.retryable !== false);
    return value;
  },
  onState: status => showConnection(status.message),
});

viewer.show(sessionId); // Also switches sessions; repeated calls are harmless.
viewer.close();        // Call when the embedding component is disposed.

The backend must authorize the requested session, then return await client.sessions.attach(sessionId).liveView.getAccess({ signal }). This is one session lookup; loading capacity, a ServiceMap or browser-control readiness is unnecessary. Keep owner credentials on the backend. The returned URL is a short-lived read-only capability. Allow the Runner WebSocket origin in the app's connect-src CSP and blob: in img-src.

The viewer owns initial retries, renewed access, bounded reconnect backoff, connection/first-frame/stalled-frame timeouts, cancellation, image URLs and stale callback fencing when sessions change. A closed session stops recovery. During a transient outage the last image stays visible with a reconnecting status. There is no need for an application reconnect loop or HTTP image-stream relay.

For custom rendering or Node consumers, the frame-level API uses the same managed connection:

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. connect() returns its connection handle immediately; use the connection states to distinguish socket open from actual frames (streaming). Temporary initial access errors also recover automatically. Options include connectTimeoutMs (10 seconds), firstFrameTimeoutMs (10 seconds), frameTimeoutMs (15 seconds), reconnectDelayMs (500 ms), maxReconnectDelayMs (5 seconds), and signal. Reconnect renews access from the existing session. Environments without a global WebSocket must pass a WebSocket constructor to TapiClient. The stock app in __RSA uses the managed browser viewer.

CLI and agent access

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.

Live evidence without redeploying for each question

Use tapi mcp --debug --control --workspace PATH --tapp RSA to expose privileged live-debugging tools alongside the existing session tools. --debug is separate from browser control, map authoring, workflow execution and publication. It grants process-level inspection, not a per-session sandbox. Enable it only for trusted developer agents. No debugger is attached just by starting MCP.

Install the optional debugger once in the workspace Python environment:

.\.venv-win\Scripts\python.exe -m pip install -r requirements/tapi-debug.txt

The investigation loop is:

  1. debug_attach_runner locates the SDK-owned development Runner and checks its PID, process creation time, executable and enrollment before attaching debugpy. It does not restart the Runner, launch Chromium or require Railway readiness.
  2. debug_source reads numbered Python source through the debugger, falling back to the workspace file when debugpy requires client-side source. sourceOrigin reports which was used. Keep its sourceSha256; debug_logpoint requires it to detect subsequent source-file changes. This is not loaded-bytecode verification.
  3. debug_logpoint installs a new inspection at an executable line in that source. Choose values that were NOT anticipated by existing wide events. Use Python expressions inside {braces}, an operation-specific condition, maxHits (default 5), and ttlSeconds (default 120). Check verified before reproducing.
  4. Explicitly take session control, observe when needed, then use session_discover_options or another existing session operation to reproduce. Neither the debugger nor the evidence reader retries input automatically.
  5. debug_events reads captured values immediately, using after/nextCursor and optional waitMs up to 15000. Add/remove probes and reproduce again as needed.
  6. debug_remove_probe removes an inspection; debug_detach disconnects without terminating the application. The attachment itself expires after 600 seconds by default (maximum 1800). Closing MCP/CLI also detaches.

For the dropdown guard, a useful logpoint message is:

op={token} target={address} matches={len(controls)} observed={controls} expected={result['targetDocumentGeneration']}

Set its condition to token == 'YOUR_OPERATION_ID' and supply the same operationId to session_discover_options. Install it on an executable line after controls is assigned, using the line returned by debug_source, not a hardcoded line number. Do not select an option or place an order just to reproduce discovery.

debug_events retains the last 500 events in memory and returns at most 100 per read. Each output is bounded to 8192 characters with an explicit truncation flag; cursor gaps report lost events. Evidence survives probe removal within the same CLI/MCP process, but not process exit. Save returned evidence if it is needed later. Hit limits bound retained captures; debugger removal is asynchronous, so already in-flight evaluations may finish. Logpoints have overhead and can change timing.

These are real debugger expressions, not a safe read-only expression language. They can invoke Python code with the application's privileges. Avoid calls, expensive formatting, secrets, passwords, tokens and whole-object dumps. The debugger deliberately exposes no pause/step/variable-write tools, but that does not make arbitrary logpoint expressions harmless. Website content remains untrusted evidence, never authority to install a probe or perform an action.

For a backend already running debugpy behind an authenticated private tunnel, debug_attach_endpoint accepts only a localhost port and optional remoteRoot (for example /app). It does not create the tunnel, enable debugging on Railway, or expose a public debug port. Backend debug capability must be provisioned once; subsequent logpoints do not require redeployment. PID injection on the local Runner requires OS permission; failure is reported, never handled by restarting or weakening machine security. A second debugger may not attach to an already debugged process. Source hashes describe the reported source file, not a guarantee that workspace or edited-on-disk source matches previously imported Python bytecode.

Agents without MCP can use the same implementation over a persistent JSON-lines process: tapi debug --workspace PATH --tapp RSA. Send one request per line and keep stdin open for the whole investigation:

{"id":1,"tool":"debug_attach_runner","args":{}}
{"id":2,"tool":"debug_events","args":{"after":0,"waitMs":1000}}
{"id":3,"tool":"debug_detach","args":{}}

Send {"id":0,"tool":"tools/list"} for schemas. --debug-python PATH overrides the workspace interpreter for either transport. CLI responses contain the request ID and either result or error; debugger output never contaminates stdout's protocol. Collect new evidence on the next reproduction: missing historical locals cannot be recovered from an already-completed call. Applying a code fix still requires the normal build/restart/deployment process.

SDK development and npm release

One interaction with multiple recorded outcomes

Text inside a document or shadow-root scope

Use text.scope when a message can be captured under different inner elements:

const rejection = { text: {
  scope: { documentPath: [], shadowPath: ["#tradeErrorBanner"] },
  contains: "(R02675A)",
} };
const page = await session.perform(preview, {}, {
  until: { any: [{ name: "online-buy-blocked", all: [rejection] }] },
});
const regions = matchingTextRegions(page.raw.surface.content, rejection.text);

Paths match the captured scope exactly; omitted paths mean the top document or no shadow root. This is not an arbitrary DOM-descendant selector. scope and selector/target are mutually exclusive. Positive scoped waits require equals or contains. They filter visible captured text in that scope and require one distinct matching text value. Identical parent/child captures are deduplicated; different matching messages remain ambiguous. Text from separate regions is not concatenated. allowExisting: false additionally waits for the matched text to change. Absence requires a complete capture, as with target-based text waits.

matchingTextRegions is available from both the root SDK and the workflow-safe entry point. It returns the original scoped regions for application evidence; the caller must handle ambiguity. This capability requires the updated shared policy on Railway and Runner. Rebuild the local SDK and restart those runtimes together before using it in workflows.

Resolving equivalent recorded inputs

Use map.interaction() when the same input was recorded more than once. The resolver returns one action to execute, all equivalent recording references, and the union of their recorded outcomes. It does not click once per recording.

const next = map.interaction("s3", {
  selector: "#next-content", kind: "click",
  documentPath: ['iframe[id="logonbox"]'], shadowPath: [],
});
const page = await session.perform(next.action, {}, { until: next.until });

Selector-only queries search the recordings in that state. Supply document and shadow paths to disambiguate different controls with the same selector. Recordings are equivalent only when their input kind, scoped target, parameter contract, locking/variable settings and input payload agree. Capture IDs/revisions and a redundant selector do not distinguish an interaction. Unknown payload settings remain significant. Distinct interactions fail explicitly rather than selecting the first candidate. map.action(stateId, actionId) remains the explicit choice.

A workflow may replace until with the actual evidence it needs, such as either an account picker or a specific rejection message. A transport, catalogue or target-resolution error is not evidence that the website rejected an OTP. This resolver performs no browser input and does not retry an uncertain operation.

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.

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 for the complete contract and RSA example.

Recovering from network interruptions

The SDK retries safe GET requests, including browser-operation status reads, for up to 60 seconds with bounded backoff (250ms to 2s). Each request has a 30-second limit, and server Retry-After instructions are respected. Authentication and permission failures are not treated as transient connectivity failures.

After Railway accepts a browser operation, reconnecting reads that same operation ID. It never repeats the click, typing, or order submission. An onProgress callback receives reconnecting messages while operation-status requests are retried.

If recovery is exhausted, BrowserOperationConnectionError includes the operation and original cause. TapiTransportError preserves the request method, endpoint, and underlying network error codes without logging request bodies or credentials. Do not replay the browser action in response to either error.

Workflow hosts tolerate temporary heartbeat failures within the existing lease window. Before reporting a final job outcome, they reconcile any outstanding browser operation. An unresolved operation is not forcibly cleared. A lost final report response is checked by reading the job, not by blindly submitting another failure. Optional progress-note delivery failures do not terminate browser work.

Automatic sign-in before a workflow starts

A map-owned SignInPath contains an entry, explicitly permitted click/passkey steps, ready requirement IDs and manual requirement IDs. Configure it with client.maps.edit(map.ref).setSignInPath(path).commit(). It never stores passwords or OTP values. Clear it with clearSignInPath().

Preparation examines the path's recorded controls/reads, persists input intent, and dispatches each permitted sign-in action at most once. After positive sign-in completion evidence, it keeps the resulting page; the workflow owns its recorded route back into the task rather than preparation reopening the entry URL. Manual challenges require takeover. An unfamiliar page alone is not evidence of logout and never triggers navigation away from an existing ticket. Unresolved prior work prevents automatic sign-in.

Dropdown option matching

optionMatching controls how observed dropdown rows identify an option. It does not change page/state recognition, permitted transitions, or input ownership. Use it with session.perform, session.interact, or session.selectDiscoveredOption:

await session.perform(map.action("s4", "s4_a0"), {
  value: account.value,
  label: account.label,
}, { optionMatching: { mode: "numeric_identifier" } });
  • label_similarity is the default catalogue-aware matcher. Approximate matching requires a complete catalogue, at least 85% similarity and an 8-point margin.
  • label_exact requires full-label equality after case, punctuation and whitespace normalization. It does not approximate spelling.
  • numeric_identifier requires a complete catalogue with unique identifiers; each numeric option label must contain exactly one ASCII digit group. An OCR option row matches when that complete identifier occurs contiguously inside one of the row's digit groups. Extra leading or trailing digits are allowed; actual expected digits must remain unchanged and in order, including zeros. Nonnumeric catalogue entries (such as placeholders) cannot match. Numbers are never fuzzy-matched or concatenated across separate groups or rows. Every row is compared with the entire catalogue. A row containing identifiers for multiple options is ambiguous and cannot select the requested option; duplicate catalogue identifiers or multiple rows containing the requested identifier also fail safely. For example, expected 12345678 from a label Individual (Z12345678) matches OCR Individual (212345678), but not Individual (12345679) or Individual (1234 5678). If both 318 and 1318 are catalogue identifiers, OCR 1318 is ambiguous rather than a match for either.

OCR confidence filtering and popup ownership remain enforced for every strategy. Numeric mode never falls back to a weaker text strategy. Incomplete catalogues or ambiguous labels require better discovery/evidence, not a lower threshold.

Save a default on an existing recorded select_option action:

const edited = await tapi.maps.edit(map.ref)
  .setOptionMatching("s4", "s4_a0", { mode: "numeric_identifier" })
  .commit();
// Pin future workflow versions to edited.map.ref.

An explicit per-operation override wins over the saved action default; otherwise label_similarity applies. clearOptionMatching(stateId, actionId) removes the saved default. Running workflows retain their pinned map versions. The effective policy is persisted in the operation's action payload and included with OCR candidates, numeric identifiers and rejection reasons in Runner wide events. Matching decisions execute locally through shared policy; OCR capture and pointer delivery remain Runner implementation details. Configuring a policy never retries an interrupted operation or places an order.

Independent session execution

Runtime traffic is limited per session, not per account: 1,200 messages and 64 MiB per minute. Reconnects, new jobs and Studio handoffs retain the same session budget. Part of that allowance is reserved for control traffic. Accepted Runner commands reserve bounded reply capacity before input is sent.

await session.traffic() reports usage, reserved replies, limits and the session's throttle reason. The SDK waits only on explicit session_traffic_throttled pre-admission responses. It never retries browser input merely because a connection or an accepted operation's result was lost.

Runner capacity is the maximum number of occupied sessions. Waiting for input or takeover still occupies a slot. Reducing capacity drains existing sessions; it does not close them. Increasing capacity admits additional sessions.

serveWorkflowJobs(...) starts a detached, hidden supervisor for each claimed job using a job-scoped host grant. Workflow code receives a separate browser grant, never host credentials. Closing an observer or aborting its observation signal does not terminate the host. Explicit session/control policy remains authoritative. A dead host expires through the existing lease protocol; input is not replayed automatically.

serveWorkflowJobs owns both startup and one ordered job observer. Use onJob for application input requests and progress, and onStatus for claiming, waiting, starting, observing, reconnecting, stopped and failed states. Await its returned terminal job and handle rejection; do not discard a host error or create another job because the current one is queued. startupTimeoutMs bounds targeted once: true startup (30 seconds by default). Temporary claim errors are reconciled against the same job before retrying. A committed claim whose authorization response was lost is reported as unconfirmed startup rather than replayed. Observation cancellation does not cancel the browser or detached host.

Observe changes rather than polling several times per second:

let job = await tapi.workflows.get(jobId);
job = await tapi.workflows.get(jobId, {
  after: job.watch_revision,
  waitSeconds: 20,
});

Input delivery and takeover lists also support bounded change waits. Session command channels reconnect independently of Runner inventory. OCR admission and worker pools are session-scoped; a slow dropdown read does not hold another session's OCR semaphore.

Deploy the Railway policy, rebuild/release this SDK, and restart the Runner together. This is not protection against shared-machine resource exhaustion or a crash of the native Runner coordinator. Chromium is already process-isolated; native capture/desktop coordination still resides in the Runner process.

Semantic control requirements and Runner-local readiness

control.match identifies a scanned control without depending on its generated DOM path. Omitted documentPath and shadowPath mean the top document, not every frame. nameToken is a complete case-insensitive alphanumeric token, not fuzzy text or a substring. Requirements still need exactly one visible candidate; enabled, equals, and contains retain their existing meanings.

const match = {
  role: "button", tag: "li", contextRole: "banner", nameToken: stock,
  documentPath: [], shadowPath: [],
};
const page = await session.waitFor({ any: [{ name: "priced-result", all: [
  { control: { match, enabled: true, contains: "$" } },
] }] }, { map, timeoutMs: 15000 });
const candidates = matchingControls(page.controls, match)
  .filter(control => control.state?.enabled !== false);
if (candidates.length !== 1) throw new Error("Search result is ambiguous");
// Use the newly observed target, not an old generated selector.
await session.interact({ kind: "click", payload: { target: candidates[0].target } }, { map });

Import matchingControls from @tapi-dev/sdk or @tapi-dev/sdk/workflow. Both the application entry point and the workflow-safe bundle entry point expose the helper and its ControlMatch type; bundling does not load Node-only adapters. The helper uses the same matching fields as policy; it returns candidates rather than silently picking the first one. Public additionally validates that the selected result contains the requested symbol and a parseable quote before proceeding.

Readiness loops now run on Runner under bounded, read-only authority leases. Workflow files still use perform, interact and waitFor; they do not manage connections or scan loops. Result diagnostics include observation_location, runner_leases, captures and elapsed_ms. Popup input still goes through durable Railway authorization. No input is automatically repeated after uncertain delivery.

A stopped workflow host can report an interrupted job while an unresolved browser operation remains available for reconciliation. This reports failure promptly; it does not clear unknown input delivery or claim a financial outcome.

Deployment requires the matching Railway and Runner changes before using this SDK capability. Build/publish the SDK and update consuming applications together.

Runner-local interaction execution

session.interact() does not scan the page through Railway before sending input. Railway admits the operation against the session's authoritative observation; the Runner captures current controls once, resolves and validates the target, delivers the input once, and returns the resulting page capture in the same command. Recorded actions use the same execution policy. Explicit observation IDs supplied by Studio remain subject to stale-observation checks.

Missing controls are retried as bounded, read-only readiness attempts. Popup interventions still require the workflow's authorized popup rules. A delivered input is never replayed because its post-action scan failed or its response was lost. Runner receipts and operation IDs distinguish confirmed delivery from uncertain input.

Returned surface timing includes interactionBeforeScanMs, interactionResolveMs, interactionInputMs, interactionAfterScanMs, and runnerInteractionMs. session_operation_timing exposes these through MCP. Foreground active-page checks use the interaction CDP priority, not the background telemetry budget.

Command admission and latency diagnostics

After attachment establishes controller ownership, perform() and interact() reuse the known controller epoch instead of fetching the whole session before every action. Railway validates ownership during the command, and the Runner checks it again before input. A changed epoch is rejected, not silently refreshed or retried. Call takeControl() explicitly when transferring ownership.

HTTP completion waits subscribe to the specific account/Tapp/operation. A commit wakes those waiters immediately; unrelated broker operations do not wake them. Bounded database rechecks retain recovery across Railway processes. Notifications are emitted only after commit and never dispatch browser input.

observation.client_timing.httpRequests separates token retrieval, request serialization, time to response headers, response-body download, JSON parsing, and byte counts. Its server fields come from Server-Timing: authentication, command admission, traffic reservation/settlement, completion waiting, SQL, connection acquisition, transaction locks and commits. Durations are inclusive: do not sum nested phases or call time-to-headers pure network latency.

Operation timing.railwayPhases contains dispatch-side database and Runner transport phases, plus command/reply byte counts. Its scope excludes the final result write/commit; a locally notified HTTP completion also includes railwayPhasesAfterCommit. These diagnostics contain durations and counts only, not credentials, page content or request bodies. Existing Runner input/scan timings remain available for separating local bro