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

@pilots/sdk

v0.1.1

Published

Typed client for the pilots API: sandboxes and services on one primitive

Readme

@pilots/sdk

The typed JavaScript client for the pilots API: instant sandboxes and durable production services on one primitive, Firecracker microVMs.

Zero runtime dependencies. ESM only. Node 22 or newer, Bun, or Deno: the streaming exec is built on node:stream, so a browser needs a bundler that shims it.

npm i @pilots/sdk

Construction

import { PilotsClient } from '@pilots/sdk'

const pilots = new PilotsClient(process.env.PILOT_API_KEY!)
const machine = await pilots.machines.create({ name: 'demo' })
console.log(machine.url) // https://demo.pilotrun.app

The base URL is read from opts.baseURL, then PILOT_API_URL, then https://api.pilotrun.app. Every host serves the identical API, so any host in the fleet is a valid endpoint: there is no control-plane tier to be down, and a write that arrives at the wrong host is forwarded by hostd itself.

| Option | Default | Notes | | --- | --- | --- | | baseURL | PILOT_API_URL or https://api.pilotrun.app | Trailing slashes are stripped. | | fetch | globalThis.fetch | Wrap it to add retries, pooling or tracing. | | timeoutMs | 30000 | JSON calls only. Builds, log follows and streams get no deadline. | | WebSocket | globalThis.WebSocket | The seam for the ws package on an older runtime. | | org | none | Makes an ADMIN key act as one org. Every request carries ?org=. |

org is for a process that serves many orgs from one operator key. hostd reads it as the org to create rows in, charge quota to, and narrow every read by, so the rows the process creates belong to the person who asked for them. A tenant-scoped key already has exactly one org, so hostd ignores the parameter there and setting it changes nothing.

An empty key throws before any request is made.

Methods

One method per route, grouped by the noun it acts on.

| Call | Route | | --- | --- | | health() | GET /v1/health | | whoami() | GET /v1/whoami | | machines.create(req) .list() .get(id) .destroy(id) | /v1/machines | | machines.exec(id, req) | POST /v1/machines/{id}/exec | | machines.execStream(id, argv, opts) | GET /v1/machines/{id}/exec/stream (WebSocket) | | machines.logs(id) .followLogs(id) | GET /v1/machines/{id}/logs | | machines.suspend(id) .wake(id) .stop(id) .start(id) | POST /v1/machines/{id}/… | | machines.checkpoint(id, {comment}) .listCheckpoints(id) | /v1/machines/{id}/checkpoints | | machines.promote(id, req) | POST /v1/machines/{id}/promote | | machines.volume(id) | GET /v1/machines/{id}/volume | | checkpoints.restore(id) .get(id) | /v1/checkpoints/{id} | | builds.create(tar) .createFromRepo(ref) .logs(id, {follow}) | /v1/builds | | services.create(req) .list() .get(id) .patch(id, req) | /v1/services | | services.deploy(id, req) .rollback(id) .releases(id) .deploys(id) | /v1/services/{id}/… | | domains.add(req) .list() .remove(hostname) | /v1/domains | | volumes.create(req) .list() | /v1/volumes | | hosts.list() | GET /v1/hosts | | apiKeys.create(req) .revoke(hash) .list(org) | /v1/api-keys | | quotas.get(org) .put(org, quota) | /v1/quotas/{org} | | usage.get({since, until}) | GET /v1/usage | | compose.plan({compose, env}) | POST /v1/compose/plan |

services.patch replaces rather than merges: env, secret_env and replicas overwrite what is stored and take effect at the next deploy. knobs are refused there with a 400 naming the field and travel on services.deploy. volume on a service create is create-only and pins replicas to one; the patch refuses it as an unknown field and refuses replicas above one on a service that mounts a volume.

A service read carries depends_on, the sibling services in the same app whose <name>.internal address this one's environment references. hostd derives it on every read from both halves of the environment and stores it nowhere, so it says what the service is configured to dial right now. Names only, never values. usage.get answers for the ONE host it reached, so a fleet is the sum of a call to each; a suspended machine bills storage only.

Every wire type is exported under the name hostd's Go struct carries, with the JSON tags as its properties. The types from hostd's compose package carry a Compose prefix, so compose.Step is ComposeStep. A test in this package parses hostd's source on every run and fails when the two sides drift.

health() carries store_version, the sum of that host's replica version vector: how many changes, from every host, it has applied. Comparable across hosts, so two hosts far apart on it are a replication problem. 0 on a single-box SQLite host, which has no replica.

Every machine carries last_start and last_start_at, which say how it last came up:

| last_start | What happened | | --- | --- | | restore | its memory image was resumed -- the fast path, and the usual one | | boot | a kernel boot, which a create with an image or a volume pays once, and every redeploy | | cold_boot | a restore that was downgraded: no host of the memory image's CPU vendor was alive, so the machine booted from its own disk |

A cold boot keeps the id, name, URL, volume, agent token and every byte on disk. It loses running processes, everything in memory, and open connections. It is automatic and uniform -- availability wins over continuity -- so a client that cares reads this field rather than a knob it can set. A machine that has not started since the field existed reports neither.

health() also carries cpu_vendor, which pool that host restores memory images from, and hosts() carries each host's, so the fleet's split is visible without a shell on every box.

The front door

const { plan, detected } = await client.plan(tarOfMyDirectory, { app: 'shop' })

plan() posts a tar of a directory and answers with the plan an executor runs plus one detected entry per step saying where it came from: a compose file, a Dockerfile, or a framework recipe. It is on the client rather than under compose or services because it is what a caller reaches for before it knows which of those a directory is.

const { plan } = await client.planRepo({ repo: 'you/shop', ref: 'main' }, { app: 'shop' })

planRepo() names a repository instead of sending one. The host fetches the ref through the fleet's GitHub App, the same path a push takes, so a caller that holds no repository bytes can still plan. A fleet with no App configured answers not_configured and says to send a tar instead.

Errors

Every non-2xx throws. The subclass tells you what to do about it.

| Class | When | Carries | | --- | --- | --- | | PilotsError | any failure | status, body, message, code, next, details | | NotFoundError | 404 | as above | | QuotaExceededError | 429 | quota, limit, used, scope | | ComposePlanError | a compose plan hostd will not accept | unsupported: [{service, key, message}] | | BuildFailedError | a build that failed | buildId, lines | | HealthGateError | 422, a release that never became healthy | details: {service, replica, release, grace_sec, last} | | UnknownFrameworkError | 400, a directory the platform cannot place | details: {dir, looked_for, listing, manifests, workspaces, rules} |

Three fields are on the base class and therefore on every error. code is a stable snake_case noun to branch on, from the closed list in apps/hostd/internal/api/errors.go. next is the one thing to do about it, naming the command or the call. details is typed per code. They are on the base rather than only on the subclasses so that a code this version has never heard of still reaches the caller with its next step attached, instead of being dropped on the way through.

The last two are matched on code and never on the status alone: 422 is the shape of the health gate's answer today, and a later 422 for something else must not arrive typed as this one.

quota names which ceiling was hit, so a caller raises the right one rather than guessing from a sentence. scope is "host" when the limit is the host's rather than the org's, which is how builds are limited.

HealthGateError's details carry NO address. The probe target is the host's own view of the replica, inside a network namespace, and it is not reachable from wherever the error is being read.

Streaming exec

const stream = pilots.machines.execStream('m-…', ['bash', '-c', 'npm run build'], {
  cwd: '/home/pilot/app',
  env: { NODE_ENV: 'production' },
})
stream.stdout.pipe(process.stdout)
stream.stderr.pipe(process.stderr)
const code = await stream.wait()

Three things about this are worth knowing before you rely on it.

stdin is false by default. A process holding an open stdin it never reads hangs, and an agent run under claude -p is exactly such a process. Pass {stdin: true} to opt in, then use writeStdin(chunk) and endStdin(); both throw otherwise.

A text verdict LEADS the binary exit frame. hostd sends {"type":"exit","exit_code":n} and then frame 3, in that order, because the binary frame carries the code in one byte: a command killed by a signal has an exit code of -1, which one byte reports as 255 and no reader can tell from a command that genuinely exited 255. wait() settles on whichever verdict arrives first and closes the socket, so the text one has to be first for the untruncated code to be the one you get. Frame 3 still follows it, unchanged, for a client that reads only binary frames.

An exec that names no user runs as pilot. The guest image bakes that account at uid 1000 with home /home/pilot and Node 24 on PATH, so a command needs neither a user nor a cwd to land where these examples assume. sprite is a second name for the same uid and the same home, kept so a client written against sprites.dev resolves; prefer pilot, or name neither.

An interactive terminal

tty: true runs the command on a pseudo-terminal, which is what an interactive shell, tmux and vim need and what three pipes cannot give them.

const term = pilots.machines.execStream('m-…', ['bash', '-l'], {
  tty: true,
  rows: 40,
  cols: 120,
})
term.stdout.pipe(process.stdout)
term.writeStdin('ls\n')
term.resize(100, 30)

It is a mode on the same stream, not a second protocol: the frames, the ids and the exit verdict are identical. Four things change, and only under tty.

  • A PTY has one device, so everything the command writes arrives on stdout and stderr never produces a byte.
  • stdin is implied and forced on. {tty: true, stdin: false} contradicts itself and hostd answers it with a 400 before the machine is even woken.
  • endStdin() sends EOT (0x04) to the terminal instead of closing an input, because a terminal has no separate write end to close. The session stays open: what EOT means is the shell's decision.
  • resize(cols, rows) sends {"type":"resize","cols":N,"rows":N}. It throws on a stream opened without tty.

rows and cols set the initial window, default 24 by 80, each 1..65535. A value outside that closes the socket with 1008 rather than being clamped.

A close with no exit frame is an error. wait() rejects rather than resolving 0. The guest agent drains both output pumps before writing the exit frame and websocket frames are ordered, so an exit frame means every byte that preceded it has already arrived. A socket that dropped instead means nobody knows what the command did.

An unread stream grows. stdout and stderr are PassThroughs, and a WebSocket cannot be paused, so those buffers are the only boundary. Read them, or use the buffered machines.exec for output nobody intends to read.

The key travels as the authorization.bearer.<key> subprotocol rather than a header, because browsers cannot set handshake headers and one code path is easier to get right than two. hostd accepts either form. On a runtime with no global WebSocket, pass one through ExecStreamOptions.WebSocket.

Builds

const build = await pilots.builds.create(tarStream)
for await (const line of build) console.log(line.step, line.line)

Or, when only the outcome matters:

const rootfsBuildId = await (await pilots.builds.create(tarStream)).result()

hostd answers 200 before the build starts, so a ten-minute build is watchable while it runs. That means the status code cannot be the verdict: the last line is. result() reads it, and throws BuildFailedError both when that line carries an error and when the stream ended with no verdict at all.

close() walks away from a build without draining it, releasing the socket rather than holding it until GC. A result() afterwards throws, because a stream nobody finished has no verdict and an abandoned build must never read as a successful one.

const build = await pilots.builds.createFromRepo({ repo: 'you/shop', ref: 'main' })

createFromRepo() builds a repository by naming it. The host fetches the ref through the fleet's GitHub App, plans it, and builds the one step a plan may produce. A plan with more than one step is refused with plan_multi_service, readable at the build's own log.

const build = await pilots.builds.create(tarStream, { deploy: 'svc_1' })
const rootfsBuildId = await build.result()
build.release // the deployment the HOST cut from that image

deploy asks the host to cut that service a release from the image, on the build's verdict, exactly once. Nothing on this side of the connection decides whether it happens: a caller that walks away mid-build still ends with a release, and two callers watching one build still produce one rollout. A refusal after the image exists -- a health gate that never passed, most of all -- arrives as the log's last line, carrying the same error, code and next POST /v1/services/{id}/deploy would have answered with.

@pilots/sdk/tanstack

pilots as a TanStack AI sandbox provider, so an agent built there runs its tool calls on a machine.

import { pilotsSandbox } from '@pilots/sdk/tanstack'

const sandbox = pilotsSandbox({ apiKey: process.env.PILOT_API_KEY })

@tanstack/ai-sandbox is an optional peer: the contract's types are mirrored structurally in this module, so the core package keeps its zero dependencies and a consumer who never imports this entry point never installs the peer.

Three things about the mapping are worth knowing.

  • A snapshot is a checkpoint, and a restore is in place. snapshot() returns a checkpoint id and restoreSnapshot() puts that state back on the same machine, which is what keeps the URL. A restore that created a machine would mint a new address.
  • ports.connect() answers the machine's permanent URL and opens nothing: a machine already serves one HTTP port on an address that survives suspend, wake, restore and redeploy.
  • The caller's sandbox id becomes the machine's NAME. That is what makes the URL reconstructable from run context, and create adopts a machine whose name already matches rather than making a second one.

capabilities() reports snapshots, durableFilesystem, writableStdin and killableProcesses true, and fork false: cloning one machine's disk into another is on the roadmap, and a flag has to say what is true today.

@pilots/sdk/next

pilots as a Next.js deployment adapter, so a Next build tells the platform what it produced instead of the platform guessing afterwards.

// next.config.js
module.exports = { adapterPath: '@pilots/sdk/next' }

or, with no config change at all:

NEXT_ADAPTER_PATH=@pilots/sdk/next next build

It does two things and deliberately not more.

  • modifyConfig turns on output: 'standalone' for a production build. That is the highest-value thing a host can do to a Next build and the one most often missed: standalone makes Next trace what each entrypoint actually needs and emit a self-contained server, instead of the image carrying the whole repository and a full node_modules. A project that has already chosen an output mode keeps it -- output: 'export' is a static site, and overriding it would break the build for a gain of nothing.
  • onBuildComplete writes one file, <distDir>/pilots-deploy.json: the static and prerendered paths the router can serve without waking the machine, the redirect / rewrite / header rules it can answer at the edge, the per-kind counts, and the warnings a person should read. One file, because a second copy of a contract is a thing to keep in sync, and the traced asset lists stay Next's to own rather than ours to duplicate.

A rule carrying a has or missing predicate is marked conditional: true and left otherwise intact. A consumer that does not implement Next's predicate dialect must skip such a rule rather than apply it: an unconditionally applied conditional redirect is a redirect loop.

Routes built for the edge runtime become a build-time warning. pilots has one primitive -- a microVM running Node -- and no separate edge tier for them to land on, so they run on Node. That is usually fine and occasionally not, and saying it once at build time beats discovering it at runtime.

This entry point is dependency-free and imports nothing from the client: it runs inside a build, where the pilots API is not reachable.

The subpath is exported with a default condition rather than import alone, and that is load-bearing. Next resolves an adapter with require.resolve before importing it, and a CJS resolve of an import-only subpath fails with ERR_PACKAGE_PATH_NOT_EXPORTED -- so adapterPath: '@pilots/sdk/next' would fail at config load. test/next.test.ts drives the real resolver against the real exports map to keep it that way.

@pilots/sdk/sprites-compat

A sprites-shaped face over the same client, so a codebase written against the sprites SDK moves by changing one import line.

import { SpritesClient, type ExecResult } from '@pilots/sdk/sprites-compat'

const client = new SpritesClient(process.env.PILOT_API_KEY!, { timeout: 300_000 })
const sprite = await client.createSprite('demo')
const out: ExecResult = await sprite.execFile('bash', ['-c', 'ls'], { cwd: '/home/sprite/app' })

Four rules decide the shapes here.

  • sprite.id is the machine's NAME. A sprites consumer persists the id and hands it straight back as a path segment, and the alias serving those paths resolves names. sprite.machineId carries the m-… id for calls made through the typed client. Either form works as an argument: a value that matches no name but looks like a machine id is looked up as one.
  • restoreCheckpoint restores in place. Exactly one request, POST /v1/checkpoints/{id}/restore, and no machine is created. A machine created in a restore would get a new URL, and a URL is permanent.
  • createCheckpoint and restoreCheckpoint return a Response. Its body is one NDJSON line, which is what a sprites consumer reads with .text() and scans backwards for id.
  • setPublicUrl is a no-op. A workload's URL is public here by default, so there is nothing to switch on.

spawn is synchronous, as the sprites SDK's is, so it needs a sprite that has already been resolved: use createSprite or getSprite rather than the lazy client.sprite(name).

Porting crisp from @fly/sprites

The reference customer's coupling to its provider is one file, lib/sprites-client.ts. The whole port is six changes.

  1. package.json: "@fly/sprites": "^0.0.1" becomes "@pilots/sdk": "^0.1.0".
  2. lib/sprites-client.ts:1-2: the two imports become import { SpritesClient as OfficialSpritesClient, type ExecResult } from '@pilots/sdk/sprites-compat'.
  3. lib/sprites-client.ts setPublicUrl: the body becomes await this.client.setPublicUrl(spriteName). It cannot stay as it is: the current body is a raw fetch to a hard-coded https://api.sprites.dev, which no adapter can intercept and which answers 401 to a pilots key.
  4. lib/sprites-client.ts getSpritesClient: read PILOT_API_KEY instead of SPRITES_TOKEN, and name it in the error text. The constructor call is unchanged, because the adapter reads PILOT_API_URL itself.
  5. modules/sprites/actions/create-sprite.ts: drop the five nvm lines. Node 24 is on the image. /home/sprite/app stays: the guest's home is /home/pilot and /home/sprite is a symlink to it, kept so a hardcoded path resolves.
  6. .env.example: the PILOTS_API_URL / PILOTS_TOKEN / SPRITES_TOKEN block becomes PILOT_API_URL= and PILOT_API_KEY=.

StreamingCommand and spawnStreaming are untouched. They build their own WebSocket URL against /v1/sprites/{name}/exec and send the key in a header, which is exactly what hostd's name-keyed alias serves, across hosts included.