@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 loginUse 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 RSARunner 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: stringShared 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 --publishMCP 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.txtThe investigation loop is:
debug_attach_runnerlocates 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.debug_sourcereads numbered Python source through the debugger, falling back to the workspace file when debugpy requires client-side source.sourceOriginreports which was used. Keep itssourceSha256;debug_logpointrequires it to detect subsequent source-file changes. This is not loaded-bytecode verification.debug_logpointinstalls 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-specificcondition,maxHits(default 5), andttlSeconds(default 120). Checkverifiedbefore reproducing.- Explicitly take session control, observe when needed, then use
session_discover_optionsor another existing session operation to reproduce. Neither the debugger nor the evidence reader retries input automatically. debug_eventsreads captured values immediately, usingafter/nextCursorand optionalwaitMsup to 15000. Add/remove probes and reproduce again as needed.debug_remove_proberemoves an inspection;debug_detachdisconnects 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 publicThe 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_similarityis the default catalogue-aware matcher. Approximate matching requires a complete catalogue, at least 85% similarity and an 8-point margin.label_exactrequires full-label equality after case, punctuation and whitespace normalization. It does not approximate spelling.numeric_identifierrequires 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, expected12345678from a labelIndividual (Z12345678)matches OCRIndividual (212345678), but notIndividual (12345679)orIndividual (1234 5678). If both318and1318are catalogue identifiers, OCR1318is 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
