Programmatic API
Install the main package as a project dependency:
npm install --save-dev stim
Node 22.12 or later is required. The package exports the API and also installs
the stim executable. Command examples use stim; replace it with npx stim
when it is not installed globally.
import { createStim } from 'stim';
const stim = createStim({
projectRoot: process.cwd(),
onProgress: ({ message }) => process.stderr.write(message),
});
try {
const result = await stim.run({ platform: 'ios' });
console.log(result.facts.udid);
} finally {
try {
const diagnostics = await stim.diagnostics({ errors: true });
console.log(diagnostics.directory, diagnostics.records);
} finally {
const cleanup = await stim.stop();
if (!cleanup.ok) throw new Error(cleanup.summary);
}
}
Client context
createStim resolves projectRoot to its canonical path once. It accepts
optional absolute home and buildCache paths, an env object, and an
onProgress callback. env defaults to the caller's process.env; Stim reads
its settings overrides from it and passes it to its child processes. Omitted paths use
that environment, captured when the client is created, and normal Stim
configuration. The client's home is fixed at creation.
Progress events contain stream (stdout or stderr) and message, an output
chunk which can contain partial lines. Without a callback the API stays silent.
All operations run in the importing process against the client's home and project and invoke the same lifecycle operations as the CLI, so clients for different homes can share a process. None parses Commander arguments. The importing process keeps its working directory, environment, output and signal handlers.
The exported TypeScript contracts are StimOptions, StimClient,
StimBuildOptions, StimBuildResult, StimRunOptions, StimRunResult,
StimStopOptions, StimStopResult, StimStartOptions, StimStartResult, StimReloadOptions, StimReloadResult, StimDiagnosticsOptions,
StimDiagnostics, StimLogsOptions, StimReadOptions, StimSettings,
StimStats, StimProgress, StimPlatform, StimWorktreeRemoveOptions, and StimWorktreeRemoveResult.
Build only
const result = await stim.build({ platform: 'ios', configuration: 'Debug' });
console.log(result.facts.appPath);
console.log(result.facts.links.workspace);
build uses the selected project's compiler recipe and normal build/cache
coordination. It does not create or boot a device, launch an app, start Metro,
or stop a session. Results are inferred from platform, just like run.
| Platform | Options | Returned artifact |
|---|---|---|
ios | scheme, configuration, arch (arm64, x86_64, all), buildCache, remoteBuild | facts.appPath, a simulator app |
android | variant, abi (arm64-v8a, armeabi-v7a, x86, x86_64, all), buildCache, remoteBuild | facts.apkPath |
macos | remoteBuild | facts.bundle, the configured Debug app |
A Debug build targets the host architecture by default; on Android only while
optimizations.android.targetAbiOnly is on, which is the default. Release uses
all architectures. Each result points to a separate owned copy retained until
stim worktree remove removes this workspace; a later build does not overwrite
it. A build does not replace a running app's last build record or build log; it
writes its own build-artifact-<platform>.ndjson. Build-only does not export
distribution archives or infer a web compilation pipeline.
Use buildCI from @stim-cli/ci to export the app and diagnostics for CI artifacts.
Successful builds also return facts.links.workspace, the machine-aware HTTPS
workspace link agents can share with an already-paired phone or Desktop.
It is available when the build machine has no Desktop installed. Opening it
only navigates to the workspace; it does not install or launch the built app.
Run
run builds, installs and launches with the same requirements and configuration
as the corresponding CLI command.
Unresolved native Metro cleanup preserves its claim roots in
error.details.cause.retainedClaimRoots and reports STIM_CLAIM_UNAVAILABLE
even when the run was cancelled. A web run cancels pending browser lock and
verification waits. Cancellation during a new browser launch waits for its
supervisor to register and be stopped, or refuses with retained ownership when
cleanup cannot be confirmed.
| Platform | Project | Options |
|---|---|---|
ios | React Native or Expo | configuration, scheme, deviceType, runtime, simulatorApp |
android | React Native or Expo | variant, systemImage, deviceProfile |
macos | SwiftPM macOS app | remoteBuild, remote |
web | Existing web server | headed (default false) |
The iOS and Android requests also accept slot, metroCheck, buildCache,
remoteBuild, remote, device, wait, and easProfile. device: true selects a
physical device automatically; a string pins its UDID or serial. wait takes
seconds as a string, such as "30". wait: false refuses an owned-device slot
wait immediately; for a physical device it bypasses another workspace's lease
and can replace that workspace's running app, as CLI --no-wait does.
easProfile downloads an existing compatible development build, never starts
an EAS build. iOS simulatorApp selects the existing simulator viewer backend.
The remote option selects the launch host with the
same placement and approval rules as the CLI; remoteBuild selects the compiler
machine independently. macOS requires a named hosting Mac and refuses auto,
eas, and proxy. Project configuration still supplies defaults. A web run
requires a running server, as stim web does.
Results are { platform, facts }, inferred from the requested platform.
For example, run({ platform: 'ios' }) returns iOS facts directly. If the input
platform is a union, narrow the result's platform to access platform-specific
facts. iOS includes udid, Android includes serial,
and both retain appPath, bundleId, metroPort, cacheHit, and launched.
macOS includes bundle, bundleId, executable, pid, build, and launched.
Hosted macOS results have a null local pid and preserve the host's true or
'unverified' launch evidence.
Web retains its browser/page launch facts.
Native launched can be true, 'bundling', or 'unverified'. A successful
operation does not turn unverified launch evidence into application readiness.
Test runners should wait for their own application-specific ready state.
Start the dev server
const facts = await stim.start({ waitSeconds: 90 });
console.log(facts.port, facts.alreadyRunning);
start({ remote?, resetCache?, waitSeconds?, signal?, beforeStart? }) starts or
reuses the app's managed dev server. remote requests its configured tunnel or
public URL; resetCache restarts it with a cleared Metro transform cache.
waitSeconds is a positive whole number, defaulting to 60. The result contains flat start facts: port, supervisorPid, mode, logsDir,
agentDevice and alreadyRunning.
beforeStart(workspace) runs once with the resolved app before usage recording
or startup effects. Throwing refuses the operation. Server adapters can reserve
that exact app even when the client selected a child directory.
Cancellation stops pending probes and lock waits, then accounts for newly
spawned tunnels and supervisors before returning. A verified supervisor handed
off to the workspace may remain running. If cleanup cannot be confirmed,
STIM_CLAIM_UNAVAILABLE takes precedence over cancellation or a failed progress
callback; follow its remedy before retrying. Startup never retries automatically.
Reload
const facts = await stim.reload({ platform: 'ios' });
console.log(facts.strategy, facts.targets);
reload({ platform?, signal?, beforeReload? }) requests a JavaScript reload in
the live iOS/Android app (owned, on a physical device or in an EAS Simulator
session) or reloads the owned Chrome page. Select ios, android
or web when more than one platform is live. The result is the flat
stim reload --json payload: platform, deviceId, deviceName, appId
(null for an app Stim did not launch), metroPort, strategy, targets and
reverseRestored.
A dispatched request does not prove completion. Verify the expected UI and
inspect logs; a Metro broadcast can reach other connected clients.
beforeReload(workspace) runs once after workspace resolution and before
usage recording or dispatch. Throwing refuses the operation, allowing server
adapters to reserve that exact workspace before effects.
Reload uses a 60 second execution budget and 35 second cleanup budget, with the
same STIM_REPORT_TIMEOUT, STIM_CANCELLED and STIM_CLAIM_UNAVAILABLE behavior
as report reads. Cancellation stops pending probes and dispatch but cannot
retract a request already sent. Reload never retries automatically.
Cancellation and cleanup
Every method accepts an AbortSignal. Cancellation interrupts the operation and
waits for its owned work to settle; a read stops its child processes and rejects with
STIM_CANCELLED. Cancelling stop() while it waits for another native operation
ends that wait. Once teardown starts, it finishes cleanup before rejecting with
STIM_CANCELLED; its config, workspace-state, and lease lock waits are asynchronous. Native tools retain their existing cooperative cancellation
and ownership claims; this is not a forced-kill deadline.
Call stop() in cleanup even when run() failed partway through. Use a fresh
signal after cancellation. Stop returns { ok, outcomes, summary }; inspect
ok, because a refused teardown is not successful cleanup. stop({ slot })
retains the CLI's slot-specific behavior.
Resources belong to the workspace, not to an individual JavaScript client.
stop() can stop resources from an earlier invocation in the same workspace.
beforeStop(workspace) runs once with the resolved workspace before teardown;
throwing refuses the operation.
When projectRoot is a child directory, start() and stop() resolve the
enclosing supported app.
Use a dedicated checkout/workspace for a CI job.
Stim failures reject with StimError, whose code, message, remedy, and details
preserve failure information. Operation errors include the workspace log path
in details.logs. An exception thrown by onProgress stops further progress
and is propagated unchanged: build(), run(), warmWorktree(), doctor(), gc() and collectGarbage() cancel
their owned work; stop() and other reads finish their work first.
Warm a worktree
const result = await stim.warmWorktree({ signal });
if (result.exitCode !== 0) throw new Error('Worktree warming failed');
warmWorktree({ signal?, beforeWarm? }) copies missing ignored state from the
source checkout into the client's linked worktree in process. It preserves
existing destination entries, tracked files and the linked branch. Copies
share a read claim on the source and take an exclusive claim on the target,
so two warms cannot write the same target simultaneously.
The result is { exitCode: 0 | 1 }, without changing process.exitCode.
Progress uses onProgress. beforeWarm(workspace) admits each canonical
workspace in the resolved warming scope, including inferred apps and registered
descendants, before registration or copying; throwing refuses warming.
Cancellation drains the copy child before removing its partial entry or
releasing claims. When cleanup cannot be verified, the operation retains the
claims and reports STIM_CLAIM_UNAVAILABLE with exact recovery paths. Completed
entries remain. Warming uses a 90 minute execution budget and a 35 second cleanup
budget; timeout reports STIM_REPORT_TIMEOUT after owned cleanup.
This API does not refresh the source or install dependencies. Use
stim worktree warm --refresh when the source needs updating.
The bundled server exposes plain warming to its originating Desktop through
the private desktop-worktree-warm subscription. It admits the resolved
workspaces and drains cancellation before releasing them. Paired phones and
adopted server connections cannot invoke it; Desktop buttons migrate separately.
Remove a worktree
removeWorktree({ target?, force?, signal?, beforeRemove? }) runs the CLI's worktree removal
safety checks and resource cleanup in process. target defaults to the client's
projectRoot; relative paths resolve against that directory.
beforeRemove(workspace) receives every canonical workspace in the resolved
cleanup scope before effects, including registered descendants and pending
branch cleanup. Throwing refuses the operation.
The result is { removed, exitCode }, with exitCode equal to 0 or 1 without
changing process.exitCode. removed means the linked Git worktree was deleted.
Source checkout cleanup leaves the checkout intact and can return
{ removed: false, exitCode: 0 }. If deletion succeeds but branch cleanup fails,
the result is { removed: true, exitCode: 1 }.
force has the same destructive meaning as CLI --force, including discarding
uncommitted work. The operation uses the 60 second execution and 35 second cleanup
budgets described below. Cancellation stops pending work and drains owned cleanup;
completed actions remain applied. Progress uses onProgress, and a thrown callback
cancels the operation and propagates unchanged unless cleanup is unresolved.
Custom Expo buildCacheProvider modules are not supported. Use EAS or a Stim
cache provider.
Next-build plans
const next = await stim.plan({ platform: 'ios', signal });
console.log(next.cacheHit, next.expectedMs);
plan returns the configured iOS, Android or macOS next-build report, matching
stim <platform> --plan --json. iOS and Android accept an optional slot.
It never builds, launches or acquires a device. A predicted build refusal stays
in the report's refusal; a failure to produce a plan rejects with StimError.
It uses the same execution and cleanup budgets as the reports below.
Cache providers must settle after cancellation. If cleanup cannot finish within
35 seconds, the plan refuses with STIM_CLAIM_UNAVAILABLE and retains the download
directory; it does not delete files a broken provider may still be writing.
The server's build.plan request uses this API for registered iOS/Android
workspaces. Its private desktop-plan feature also supports selected folders and
macOS through desktop.plan.get; that method requires the authenticated Desktop
connection and is unavailable to phones and network clients. Requests wait for
the preceding plan for the same directory to finish cleanup. Desktop reads retain
structured refusals. Their optional request identity supports desktop.plan.cancel
on the same proved connection; cancellation waits for cleanup and preserves any
retained-claim refusal without disconnecting shared feeds or blocking unrelated
requests. Completed cleanup refusals remain available through that method until
acknowledged. The server bounds active and unacknowledged plans to four per
connection, refusing new plans instead of evicting recovery details. Desktop
acknowledges received cleanup refusals and keeps cancelled ones in Notifications.
Failed or timed-out requests also drain through cancellation; a cleanup refusal
takes precedence over the original request error.
Doctor and GC reports
const doctor = await stim.doctor({ platform: 'ios', signal });
const garbage = await stim.gc({ cache: 'workspaces', signal });
console.log(doctor.findings, garbage.sections);
doctor({ platform?, record? }) reads the stim doctor --json report without repairing
project files. By default it does not record a check. Pass record: true to
register a supported iOS/Android project and record its checked platforms after the
report and owned cleanup succeed. Recording waits asynchronously for the config
lock, honors signal, and rejects unavailable state. stim.runningPath is null because
you imported the library rather than running the executable. Existing remote
approval checks can refresh their own pending credential records.
requestMachineAccess({ ticket?, signal? }) requests build and device-host access
from configured remote machines and returns the iOS doctor report. A ticket must
encode 32 random bytes; omit it to generate or reuse the pending setup ticket.
The request never inherits a ticket from the process environment, grants worker
capabilities, or applies project repairs. A person approves the ticket on each worker.
It records the completed project check only after the report and cleanup succeed.
Execution is bounded to 120 seconds, with up to 35 seconds for owned cleanup.
Desktop uses the private desktop-machine-access subscription on its own bundled
server; phone, network and other local connections cannot request access through it.
gc({ cache?, olderThan?, worktrees?, measureBuildState? }) returns the GC JSON dry-run report.
It never deletes resources or shuts down idle devices. olderThan is a whole
number of days, and cache and worktrees have the CLI's scope semantics.
measureBuildState: false reports each worktree's buildState sizes only from
the disk-usage cache: a size measured over an hour ago, or before its directory
changed, is null, and no du runs.
Invalid scopes reject with StimError and their remedy. Reports can refresh
their own measured-usage caches. An explicit home or STIM_HOME preserves
the CLI's scoped-home restrictions on machine-global resources.
collectGarbage({ cache?, olderThan?, worktrees?, delete?, idleMs?, signal? })
runs the CLI's GC selections and actions in process and returns { report, exitCode }.
exitCode is 0 or 1; it does not change process.exitCode. delete: true
enables deletion. idleMs shuts down owned devices idle for that many milliseconds,
including when delete is omitted. The CLI's scoped-home restrictions also apply.
const { report, exitCode } = await stim.collectGarbage({
cache: 'workspaces',
delete: true,
signal,
});
console.log(report.results, exitCode);
A deletion or idle sweep holds this home's exclusive maintenance claim.
Concurrent maintenance rejects with STIM_CLAIM_REFUSED and names the held claim.
Cancellation stops pending lock waits and further actions; completed actions
remain applied. An archive whose history has started moving finishes publication
before cancellation. Unresolved cleanup retains its actual ownership claims and
rejects with STIM_CLAIM_UNAVAILABLE; follow its recovery remedy before retrying.
Progress goes through onProgress without replacing the caller's console methods.
These reports and mutations have a 60 second execution budget. They attempt owned cleanup
before rejecting with STIM_REPORT_TIMEOUT on
expiry or STIM_CANCELLED on a caller abort. Throwing from onProgress cancels
the report and propagates that exception unchanged. Other clients keep running.
If child identity cannot be verified after a one-second retry budget, cleanup
refuses with STIM_CLAIM_UNAVAILABLE instead of a cancellation or timeout
error. Child cleanup errors name the launcher PID; unverifiable processes and descendants
are left untouched. On POSIX, a launcher that exited before identity capture can only
finish successfully once its process group is proven gone.
After a termination request, cleanup waits for a child to close, then for its
process group to exit: each wait is 5 seconds times the host's load per CPU,
from 5 to 15 seconds. Overall cleanup has a 35 second budget, including temporary-worktree
removal; unfinished cleanup returns STIM_CLAIM_UNAVAILABLE and leaves
unresolved resources in place. Credential refresh waits asynchronously and
checks cancellation before writing.
On Windows, report probes require the built-in Windows PowerShell for process-tree containment.
A probe's time limit counts only the probe itself; PowerShell's startup and exit each have their own 30 second limit.
Settings
settings() returns every setting with its effective value, origin and
per-layer values for the client's project, the same payload as
stim settings --json. It reads in the importing process and starts no Stim
worker. An invalid environment override rejects with a StimError carrying
STIM_BAD_ARG and its remedy.
setSetting(key, text, { scope?, signal? }) and
unsetSetting(key, { scope?, signal? }) return the same JSON result as
stim settings set/unset --json: the file, scope, changed flag and updated
setting entry. Sensitive values remain masked. Pass CLI text values, including
JSON strings for array and object settings:
await stim.setSetting('ios.runtime', '26.4', { scope: 'workspace' });
await stim.unsetSetting('ios.runtime', { scope: 'workspace' });
Validation, layer precedence and committed-file handling are shared with the CLI. Lock acquisition and device-pool probes do not block the event loop. Cancellation prevents a write that has not committed. Once committed, disabling recording finishes deleting the affected recordings before the promise settles, even when cancellation arrives during cleanup. Recording changes within one home serialize through that cleanup, so a later enable cannot overtake deletion.
Both methods accept beforeWrite(workspace), a synchronous callback invoked once
after resolving the target and before mutation or recording-lock waits. Throwing
refuses the write. Server adapters can use it to admit the exact workspace being
changed; later registry changes do not redirect the write or its returned setting.
Stats
stats() returns run and build-cache counts for the client's project and the
machine, build placement, and the agent-device and SwiftPM cache usage Stim
last recorded, the same payload as stim stats --json except that it does not
measure those usage figures again. It reads in the importing process.
Logs
logs({ tail, sources, minLevel, slot, since, grep, errorsOnly, signal })
returns the same merged records as stim logs --json, including hosted logs,
native crash capture, agent actions and Expo error context. It requires logs
from a previous workspace run. Pass tail to bound the returned records;
omitting it returns every matching record. tail: 0 returns no records.
sources is a list of log sources, grep is a regular expression string, and
since accepts a duration such as 30s, 5m or 2h.
const records = await stim.logs({ tail: 2000, errorsOnly: true });
const stopLogs = await stim.followLogs({ tail: 200 }, (record) => {
console.log(record);
});
stopLogs();
followLogs(options, onRecord) emits matching backfill followed by appended
records and resolves to a stop function. Its backfill is bounded when tail
is supplied. Aborting its signal also stops the subscription; cancellation
before setup finishes rejects with STIM_CANCELLED.
Local follow uses filesystem change hints with a ten-second repair scan for missed events or a replaced directory. Until a directory watch is available, attachment and reads retry every 500 ms. Expo error context uses a settle timer only while pending. Agent-action and active hosted query sampling are unchanged. Attachment also schedules one 500 ms recovery scan. On Windows a moved log directory can require the ten-second repair scan.
Hosted targets join and leave the follow stream from local state notifications. Missed notifications are reconciled about every 10 seconds; active hosts still serve bounded log queries.
An optional third callback, onBackfill(records), receives the initial records
instead of onRecord. Its promise is awaited before the next batch, allowing a
consumer to apply backpressure. With that callback and no tail, timeline
selection uses batches of at most 500 records. Appended records still go to
onRecord. Agent attribution still retains its matching records during the initial read.
These operations run in the importing process, read files asynchronously and yield between parsing chunks. A cold file generation is scanned for timestamp bounds, markers and launches; later reads reuse that index and scan appended complete lines. Tail selection bounds matching timeline and agent records. Agent attribution still scans each retained session generation to establish device ownership, so transient parsing buffers depend on those file sizes. Caller context and process settings remain unchanged.
Diagnostics and CI state
diagnostics({ tail: 200, errors: false }) reads the existing local timeline
and returns { directory, records }. tail: 0 returns paths without records.
The directory is returned even before a run creates logs. This method does not
fetch remote logs or capture new native crashes.
Ownership validation, artifact compatibility and coordination stay active. For a disposable CI job, use its own checkout and state/cache directories. If concurrent runs write the same artifact cache, they must share its coordinating Stim home. Separate homes pointed at one writable artifact cache do not share build locks.