| name | sdk |
| description | The hard rules for editing `@kortix/sdk` (packages/sdk) — a PUBLISHED npm package with constraints no other package in this repo has: TDD is mandatory (failing test first, gates run and pasted every turn, explicit shippable YES/NO/NOT YET); exported names INCLUDING types are a public API contract and renaming one is a breaking change; the `version` field is inert and must never be hand-bumped; adding an export requires three synchronized edits; and a static import-graph tripwire enforces the framework-free core. Load WHENEVER you add, edit, rename, move, export, test, or delete anything under packages/sdk, or touch its streaming/transport layer. |
@kortix/sdk
Read the repo-root AGENTS.md first — it covers the monorepo (worktrees, the local
stack, verification standard, release topology). This file covers only what is
specific to this package, and the traps that have no analogue elsewhere in
the repo.
Three rules before you touch anything
- Write the failing test first, before any implementation code.
RED → GREEN → REFACTOR, no exceptions. A test you have never seen fail is
not a test. The full loop is in Test-driven, always below.
- Never hand back a red suite. Loop — run, read, fix, re-run — until every
test passes. Loop on the code, never on the test. Deleting, skipping,
weakening, or filtering a test to reach green is forbidden. If the test
itself is wrong, or it caught a pre-existing bug, stop the loop and say so.
- Run the gates and paste the output before you answer.
typecheck +
test + smoke:install. Then state plainly: is this shippable to
production — YES, NO, or NOT YET? Never "should work". Never "looks
correct".
This package is on npm. A mistake here lands in a stranger's build, not in your
PR. See Test-driven, always below.
The mental model: what this SDK actually is
Learn this once and most questions answer themselves.
This SDK talks to two completely different upstreams and hides the seam.
-
The Kortix platform REST API (backendUrl, e.g. http://localhost:8008/v1).
Owns everything about your work: accounts, projects, sessions, secrets,
billing, triggers, marketplace, audit. Lives in src/core/rest/projects-client/
and src/core/rest/platform-client/, over src/core/http/api-client.ts
(backendApi).
-
The session runtime — OpenCode running inside a per-session cloud
sandbox. The SDK reaches its REST API through the Kortix API proxy:
${backendUrl}/p/{externalId}/{port} → the sandbox's opencode server
The bridge between them is session readiness. A session runtime does not
exist until its sandbox is provisioned or resumed. That is what ensureReady()
(and start(), and implicitly send()) does: boots or resumes the sandbox and
resolves this session's OpenCode identity.
const kortix = createKortix({ backendUrl, getToken })
await kortix.projects.list()
const s = kortix.session(projectId, sessionId)
await s.ensureReady()
await s.send('what files are here?')
Call s.previewUrl(3000) before ensureReady() and you get SessionNotReadyError.
That is deliberate, and it is the single most important invariant in the package:
Handles are session-scoped and never fall back to whatever sandbox happens to
be globally active. A session(a, b) handle resolves its own runtime or it
throws. Silently borrowing another session's sandbox is the worst bug this
codebase can have — it sends a user's prompt into someone else's machine.
Auth is exactly one seam: getToken. It returns a Kortix PAT
(kortix_pat_…) for programmatic use, or a Supabase JWT for a logged-in web
user. Everything else — REST calls and the proxied runtime alike — flows through
authenticatedFetch, which attaches it. There is no second auth path. Do not add
one.
The layers, bottom to top
core/http/auth + core/http/api-client ← transport: token, fetch, ApiError
core/rest/*-client ← typed REST surfaces (one file per domain)
core/runtime/client ← OpenCode REST compatibility client
core/stream/event-stream ← SSE reconnect/backoff/heartbeat/coalesce
core/client/kortix.ts (createKortix) ← the facade: binds ids, hides the seam
core/turns/ ← normalizes ~50 wire part types → ClassifiedPart
browser/, node/, platform/ ← host adapters: zustand stores, ./server, env
react/ ← optional glue. Nothing below this line knows React.
src/deprecated/ holds the ~20 legacy subpath shims (./projects-client,
./event-stream, ./server-store, …). They re-export from core/ and stay
until the next major. Add nothing new there.
core/turns/ deserves a note: the opencode wire format has ~50 part variants.
It collapses them into a compile-time-exhaustive ClassifiedPart union so a
renderer can switch (part.kind) and have TypeScript prove no case is missed.
It is framework-free on purpose — examples/04 renders a transcript to plain
text with the exact same code whitelabel-demo renders to React.
Recipe: adding a new capability, end to end
Follow the grain. Almost every feature is this shape:
- REST function in
src/core/rest/projects-client/<domain>.ts. Typed request
and response, called through backendApi. Colocate <domain>.test.ts.
- Export it from that directory's barrel.
- Wire it into the facade in
src/core/client/kortix.ts as a direct
reference (create: P.createThing) — not a wrapper. Direct references keep
the exact types with zero re-typing, which is what keeps the facade around a
thousand lines instead of several thousand.
- Reactive? Then, and only then, add a hook in
react/ over the client fn.
The client fn must exist first and must work without React.
- New public name? Re-read the naming rules below. It is forever.
The invariants, in one place
- Session-scoped, never global. See above. Never resolve a runtime from
ambient state.
- Session-scoped and provider-agnostic. The sandbox provider is a server-side
concern. Every session uses OpenCode REST. Host code must not add a second
transport.
- Hosts never import
@opencode-ai/sdk. Not apps/web, not the demo. If a
host needs runtime access, it goes through session.runtime.
- Hosts never raw-
fetch the Kortix API. If the SDK doesn't expose it, add it
to the SDK.
- The core never imports a framework. Enforced statically. See the tripwire.
session_id == sandbox_id. The runtime is reached at
/p/{externalId}/{port}, never at a bare sandbox URL.
This package is published. People install it.
@kortix/sdk is live on npm (npm view @kortix/sdk version). It is not an
internal workspace library that happens to have a package.json. Code you
change here reaches installs you cannot see and cannot fix forward.
That has one practical consequence that should govern every edit: the public
surface is a promise, and the promise is enforced by tests, not by intent.
Before changing anything reachable from the exports map, ask what an external
consumer pinned to the current major would experience. If the answer is "their
build breaks", it needs a deprecation path, not a rename.
Exported names ARE the API. Renaming one breaks every consumer.
The single most common way to break this package is to rename something while
"just cleaning up". A rename compiles, passes every unit test, typechecks the
whole monorepo — and detonates in someone else's project on npm install.
Every identifier reachable from a public entry point is public API, whether
or not it looks like one:
- functions and factories —
createKortix, classifyTurn
- classes —
ApiError, SessionNotReadyError
- types and interfaces —
KortixProject, SessionHandle, ClassifiedPart.
A renamed type breaks import type { … } exactly as hard as a renamed
function. Types are not "internal because they erase at runtime".
- React hooks and components exported from
./react — useSession
- string-literal union members, enum members, and object keys returned from a
public function (
turn.kind === 'reasoning')
If a consumer can type it after import {, you own it.
What is safe, and what is not
| Change | Safe? |
|---|
| Adding a new export | ✅ additive |
Moving a file inside src/, updating exports to match | ✅ invisible — the exports map decouples path from name |
| Renaming a symbol that is never exported | ✅ |
| Widening a parameter type, narrowing a return type | ✅ |
| Renaming any exported symbol | ❌ breaking |
Renaming a type or interface | ❌ breaking |
| Changing a string-literal union member | ❌ breaking |
| Removing an export | ❌ breaking |
| Making an optional field required | ❌ breaking |
Note the second row: internal file layout is free to change; public names are
not. These are independent. You can restructure src/ completely without
touching a single consumer, as long as exports still resolves and the names
that come out the other side are unchanged.
If you must rename, alias — never replace
Keep the old name working, mark it, and delete it only on a major:
export interface Project { }
export type KortixProject = Project;
A rename without an alias is not a refactor, it is a breaking change wearing a
refactor's clothes.
Name it right the first time
Renames are expensive, so front-load the thinking. Conventions for this package:
- Factories are
createX (createKortix). Errors are XError extending
Error (ApiError). Hooks are useX. Types are PascalCase, never
IFoo.
- For NEW names, don't prefix with
Kortix unless the bare name would collide
with a common global or DOM type (File, Event, Response, Request).
KortixFile is legitimate; a bare Session is not.
But never rename an already-published name just to satisfy this rule. A
rename costs consumers real breakage; a slightly-verbose name costs nothing.
Additive is free, subtractive is a major — that asymmetry outranks style. This
is why KortixProject keeps its name (see below).
- A prefix earns its keep when it disambiguates.
KortixProject (the platform
entity) and KortixMasterProject (the sandbox daemon's board project) are two
genuinely different concepts. Here the prefix is doing real work.
- No abbreviations in public names.
configuration over cfg, session
over sess.
- Every public name must be globally unique across the whole surface. With a
single root barrel this is enforced by the compiler — two modules exporting the
same name is a
TS2308 build error, not a silent shadow.
KortixProject was declared twice — the platform project
(core/rest/projects-client/projects.ts, keyed project_id/account_id/repo_url)
and the kortix-master daemon's board project (core/runtime/kortix-master.ts, keyed
id/path/opencode_id/structure_version). Same word, unrelated concepts;
the split subpath surface hid the clash for months.
Resolved: the platform type keeps KortixProject. The daemon type becomes
KortixMasterProject, with export type KortixProject = KortixMasterProject
retained as a @deprecated alias on the opencode-client shim so no importer
breaks. This is the alias-never-replace rule applied to itself.
The guardrail that actually enforces this
Documentation gets skimmed. The rule is enforced by a committed snapshot of
every public export name, asserted in CI. Renaming, removing, or adding an
export changes the snapshot, and the diff lands in code review where a human
decides whether it is additive (fine) or breaking (needs an alias and a major).
Treat a snapshot diff as a question — "did I mean to change the public API?" —
not as a test to re-record until it goes green.
Versioning: never touch the version field
packages/sdk/package.json carries a placeholder version (0.3.0 today).
It is inert. Nothing reads it.
At publish time scripts/stage-npm-publish.mjs overwrites it:
pkg.version = version;
The SDK ships in lockstep with the platform release. The root VERSION file
is the single source of truth; deploy-prod.yml → scripts/publish-npm-package.sh
stamps it in.
- Do not bump
version in packages/sdk/package.json. It is a no-op.
- Do not write a plan step that says "bump the SDK to
x.y.z". Releases bump
the SDK; the SDK does not bump itself.
- A version below the current npm
latest would publish but never become
latest, silently stranding every consumer on ^-range installs.
Publishing is idempotent (re-running a release skips a version already on npm)
and auth'd via Trusted Publishing/OIDC, falling back to NODE_AUTH_TOKEN. With
neither, it skips cleanly rather than failing the release.
The core law: the core is framework-free
The root barrel (@kortix/sdk) and most subpaths must run in a browser
<script> tag, a React Native app, a Node CLI, and a Cloudflare Worker —
with no framework in the import graph at all. React and React Native are
optional layers on top, never a dependency of the core.
This is not a style preference. It is asserted statically by
src/index.isomorphic.test.ts, which walks the full relative-import graph
of every entry point and fails on a forbidden specifier — including
import type, because a type-only import still forces the dependency onto every
consumer's tsc run.
The three tiers
Every non-React subpath is classified in SUBPATH_TIERS
(src/index.isomorphic.test.ts). Pick the right one when you add an export.
| Tier | Entry points | Forbids |
|---|
isomorphic-core | root ., ./message-queue, and the src/deprecated/ shims (./session, ./turns, ./files, ./event-stream, ./projects-client, …) | react, react-dom, next, zustand, @tanstack/react-query, 'use client', any node: import |
node-allowed | ./server only | same, except node:async_hooks is permitted (per-request config isolation) |
browser-only | ./internal/sync-store, ./internal/server-store, ./internal/sandbox-connection-store, ./internal/opencode-pending-store, ./internal/idb-sync-cache — plus their un-prefixed @deprecated aliases | only react / react-dom / next. zustand and window/localStorage/indexedDB are expected here |
If a test named <subpath> (<tier>): no forbidden framework imports fails, you
did not "break a lint rule" — you broke the package for a host that has no
React, and the tripwire caught it. Fix the import; do not widen the tier.
The tripwire walks imports. It cannot see globals.
This is its one blind spot, and React Native is how you fall into it. process,
window, document, localStorage are globals, not imports — nothing in the
import graph reveals them. So a green tripwire proves the core is free of
framework imports; it does not prove the core runs on RN or in a
<script> tag.
Touching a bare process.env on a non-Next host throws a ReferenceError, not
undefined. The SDK knows this — core/http/feature-flags.ts documents it and
core/http/env.ts ships safeEnv() for it. Use it. Guarded reads are the rule:
if (typeof window !== 'undefined' && window.location?.origin) { … }
const url = safeEnv('BACKEND_URL') || …
const url = process.env.BACKEND_URL || …
Never introduce a bare global into core/. Guard it, or inject it.
The browser-only tier is internal machinery
Those five zustand stores are apps/web machinery, imported directly at their
use sites from @kortix/sdk/internal/*. They are outside semver. Nothing
third-party should build on them, and they are not exposed on the
window.Kortix global. Treat them as implementation detail that is
regrettably visible, not as a designed API.
apps/web/eslint.config.mjs and apps/web/scripts/sdk-boundary.mjs both list the
un-prefixed aliases (@kortix/sdk/server-store, …). Import the ./internal/
form in new code.
Adding or moving an export requires three synchronized edits
Miss one and CI fails — by design. There is no single source of truth for the
export map, so the tripwire manufactures one.
package.json → exports — the workspace path, which must be exactly
./src/<file>.
package.json → publishConfig.exports — the published path, ./dist/<file>.js
plus its .d.ts. Workspace consumers resolve src/; npm consumers resolve
dist/. Both must exist.
SUBPATH_TIERS in src/index.isomorphic.test.ts — name, entry file, tier.
The test SUBPATH_TIERS matches package.json exports (minus "." and "./react")
asserts (1) and (3) are set-equal and that each exports value literally
equals ./src/<file>.
For (2), know exactly what is and is not covered:
- ✅
scripts/stage-npm-publish.mjs:60-80 runs on every PR and fails if any path
in publishConfig.exports is missing from the emitted dist/.
- ✅
src/package-exports.test.ts:15-21 asserts exports and
publishConfig.exports declare the same key set (and that each
publishConfig entry carries both types and import). Add ./foo to one
map and forget the other, and this test goes red — before npm install @kortix/sdk can ship a subpath that resolves in the workspace but not on npm.
- ✅
scripts/smoke-install.mjs — run by the packages lane
(tests/bin/package-quality.ts), locally via pnpm test -- --packages-only or
pnpm --filter @kortix/sdk run smoke:install — packs the
tarball, installs it into a throwaway project, and imports it, so a resolution
or runtime failure in the published artifact fails a PR instead of a stranger's
build.
So both of those old gaps are now guarded — but a green CI is still not a promise
the package is perfect. Nothing loads the IIFE global in a real browser, and the
runtime-target matrix (Safari, RN, Workers) is only partly exercised. Read the
diff; don't outsource the judgment to the checkmarks.
./react is deliberately excluded from the tripwire. It is the one place React
belongs.
Dual entrypoint: src/ in the workspace, dist/ on npm
"main": "./src/index.ts",
"publishConfig": {
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}
npm view @kortix/sdk main returns ./dist/index.js, so the swap works today.
scripts/smoke-install.mjs (the packages lane, or pnpm --filter @kortix/sdk run smoke:install) now exercises an actual install — it packs,
installs the tarball into a throwaway project, and imports it — so this swap is no
longer unguarded. It is still the subtlest thing a refactor can quietly break, so
when you touch entry points, run it: npm pack → install the tarball into a
throwaway project → import it is exactly what it does.
Workspace dependencies get pinned at publish
stage-npm-publish.mjs rewrites every workspace:* dependency to the concrete
lockstep version. @kortix/llm-catalog is workspace:* here, so it must be
published at the same version or npm install @kortix/sdk fails to resolve.
Adding a new workspace:* dependency to this package therefore also means making
that package publishable. Prefer not to.
react and @tanstack/react-query are optional peer dependencies. Never
promote them to dependencies.
Streaming is the fragile part. Treat it as a first-class target.
Live SSE streaming (session.stream() → openEventStream → client.global.event())
is the single most breakable surface in this package, because it is the only one
that depends on streaming-body support in the host's fetch — a thing that
differs across every runtime we claim to support.
The transport is not EventSource. It is, inside
@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js:
const response = await _fetch(request);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
So streaming requires fetch with a real ReadableStream body, plus
TextDecoderStream. Reconnect, backoff, heartbeat, and event coalescing are
ours (src/core/stream/event-stream.ts); the wire is theirs.
| Target | Streams? | Notes |
|---|
| Modern browsers | ✅ | TextDecoderStream needs Safari 16.4+ |
| Node ≥ 18 | ✅ | fetch + TextDecoderStream are global |
| Bun | ✅ | |
| Cloudflare Workers | ✅ | |
| React Native / Expo | ❌ not supported | RN's fetch has no response.body; Hermes has no TextDecoderStream. The SDK's streaming cannot run on RN today. |
This SDK ships to three hosts: apps/web, apps/mobile (RN/Expo), and
apps/whitelabel-demo. A change that works on web and breaks the others is a
broken change, not a partial one.
Do not claim React Native streaming support — in the README, the docs, or a
PR description. It does not work. apps/mobile streams today only because it
bypasses the SDK entirely: apps/mobile/lib/opencode/event-stream.ts is
655 lines reimplementing reconnect/backoff/heartbeat/coalescing on
react-native-sse's EventSource, beside the SDK's own
src/core/stream/event-stream.ts. Two divergent copies of the most
failure-prone logic in the product.
It happened because the SDK left no transport seam. The fix — extracting an
injectable EventStreamTransport, so the platform-specific part is only how
bytes arrive and never the reconnect logic — is understood and deliberately
deferred.
Do not add a third copy. If a host needs a different wire, build the seam.
@opencode-ai/sdk/v2/client is browser-safe — its import graph is only
error-interceptor, client.gen, sdk.gen, types.gen. The node:child_process
in that package lives in dist/process.js, reachable only from v2/server.js.
Bundler trap. Never let a build resolve @opencode-ai/sdk (root) or
/server or /v2/server into a browser bundle — that drags in
node:child_process and the build breaks or silently ships a broken global.
Import @opencode-ai/sdk/v2/client and nothing else.
Streaming is not "done" because a unit test passes. It is done when it has been
observed delivering events in each distribution target you claim — the ESM
dist/, the CDN ESM bundle, and the window.Kortix IIFE global.
Field notes: what actually keeps an SDK maintainable
Longer-lived guidance than any single task. If you read one section twice, this one.
An SDK's job is to be boring. An app can be refactored on a whim because you
own every caller. A library cannot: your callers are strangers, on their own
schedule, and they will not read your changelog. The asymmetry is the whole game.
Optimize for not needing to change over being easy to change.
The public surface is the product. Internals can be ugly and get fixed later.
A bad public name is forever, or costs a major to undo. Spend your care at the
boundary, in this order: the names, then the types, then the implementation.
Additive is free; subtractive is a major. Adding an export, widening a
parameter, adding an optional field — all safe, ship freely. Removing, renaming,
narrowing, or making a field required — all breaking, always. When in doubt about
whether a change is breaking, ask: "could a consumer's code stop compiling?"
Test the package, not just the code. Unit tests prove your functions work.
They say nothing about whether npm install produces a thing that imports. Those
are different failures with different blast radii, and the second one is the one
that reaches strangers. Every published package needs a test that packs, installs,
and imports.
Types are invisible at runtime and load-bearing at build time. It is easy to
think a type is "internal" because it erases. It is not. If a consumer can write
import type { Foo }, Foo is API. This is the single most common way a
well-meaning cleanup breaks people.
Every dependency you add, your users install. A dependency is a decision you
make on behalf of everyone downstream — their bundle size, their audit surface,
their install time, their version conflicts. Prefer zero. Prefer an optional
peerDependency over a dependency. Never promote a peer to a dep for
convenience. (react and @tanstack/react-query are optional peers here, on
purpose. Keep them that way.)
Runtime targets are a matrix, not a checkbox. "Works in the browser" is four
claims: bundled ESM, unbundled ESM over CDN, a <script> global, and whatever
Safari is doing. A feature is done when it is observed working in every target the
README claims. Streaming is where this bites (see above).
Beware the dual-package hazard. If a consumer somehow loads both the ESM build
and the IIFE global, they get two distinct ApiError classes, and
err instanceof ApiError silently returns false. It is not a hypothetical —
it is why instanceof checks must be tested under the browser bundle
specifically, not just in Node.
Errors are API too. The class name, its instanceof identity, its fields, and
the string-literal code values are all things people branch on. Changing
ApiError.status to ApiError.statusCode breaks error handling in every consumer,
and no typecheck of ours will catch it.
Deprecate loudly, delete rarely. The cost of carrying an aliased old name for
a year is one line. The cost of deleting it is someone's broken build and a
support thread. Aliases are cheap; keep them.
Examples are tests. examples/*.ts typecheck in CI. When an example stops
compiling, the API got worse, and you found out before a user did. Write the
example you wish existed when you were learning, then make the API fit it — not
the other way round.
When you cannot say why a thing is public, it should not be. Every export,
every subpath, every field. The default answer is no; make each one argue for
itself. Surface you never shipped is surface you never have to support.
Commands
pnpm --filter @kortix/sdk typecheck
pnpm --filter @kortix/sdk test
pnpm --filter @kortix/sdk build
Nothing is done until typecheck, test, and the tripwire are green. The
tripwire lives inside test, so a green test covers it — but read the output;
a skipped file is not a passing file.
The packages lane (tests/bin/package-quality.ts, reached by
pnpm test -- --packages-only and by CI through .github/workflows/tests-pr.yml
→ tests.yml) additionally runs scripts/stage-npm-publish.test.mjs,
scripts/publish-npm-package.test.mjs, this package's smoke:install, and a
stage + dry-pack of @kortix/llm-catalog, @kortix/sdk, and the deprecated
@kortix/executor-sdk adapter.
That is the release gate. It catches a broken publishConfig and a broken
install; it does not catch a broken runtime target.
Test-driven, always. No exceptions in this package.
Write the failing test before any implementation code. The loop is below.
For broader repo conventions — which test type a change needs, factories,
determinism, CI gates — the project skill is .claude/skills/testing/SKILL.md.
This is not a preference. This package is published to npm. A regression here
does not show up in a PR review; it shows up in a stranger's build, on their
schedule, with no way to fix forward. Tests are the only thing standing between a
refactor and that outcome.
The loop
RED → GREEN → REFACTOR. In that order, every time.
- Write the failing test first. It encodes what you intend, before you know how.
- Run it and watch it fail. For the right reason, with the error you expect.
- Write the minimum code to make it pass.
- Run it and watch it pass.
- Refactor with the test green. Commit.
A test you have never seen fail is not a test. It is a comment that costs
CPU. If you cannot make it fail by breaking the code it covers, it asserts
nothing. Step 2 is the step people skip and the step that does the work.
Skipping RED is how you write a test that passes against a bug. Skipping GREEN is
how you ship code no test exercises. Neither is faster.
Never end a turn without running the gates
Do not report a change as done — or even as probably done — on the strength of a
typecheck, a diff that "looks right", or a test you wrote but never executed. Run
these, and paste the real output:
pnpm --filter @kortix/sdk typecheck
pnpm --filter @kortix/sdk test
pnpm --filter @kortix/sdk run smoke:install
There is no baseline number in this file, on purpose. The suite only grows
(1046 → 1069 → 1357 → 1626 → … , see PROGRESS.md), so any figure written here
is wrong by the time you read it. Get today's real count by running the full
suite once on a clean main before you start:
pnpm --filter @kortix/sdk test 2>&1 | tail -3
Whatever that prints is the baseline for your session. If your own run — after
your change — shows fewer tests than that, you did not run them all: a filtered
bun test that matches nothing exits 0 and tells you nothing.
typecheck is not verification. It proves the types line up. It does not prove
the code runs, that the tripwire holds, that the tarball imports, or that streaming
delivers an event. Each of those has its own named proof, and the named proof must
actually run.
When a test fails: keep going, but never touch the test
Do not hand back red tests. A failing suite is not a status update, it is
unfinished work. Loop: run → read the failure → fix → run again. Do not stop at
the first red, do not report "1 test failing, here's why", do not move to the next
task. Keep going until the suite is green.
But loop on the CODE, never on the TEST. The cheapest way to make a failing
test pass is to delete it. The second cheapest is to weaken its assertion. Both
reach green. Both destroy the only thing standing between a refactor and a
stranger's broken build.
These are forbidden as a response to a red test:
| Forbidden | Why |
|---|
Deleting the test, or test.skip / test.todo / .only | Green by amputation |
Weakening an assertion (toEqual → toBeDefined, dropping a field) | Green by lowering the bar |
Filtering the run (bun test src/thing.test.ts) to dodge an unrelated failure | Green by not looking |
| Re-recording a snapshot to match your output | The snapshot is the expectation. See below. |
| Declaring a behaviour change "doesn't need a test" | Nothing blocks the PR, which is exactly why it is on you |
catch {} around the thing that throws | Green by silence |
Three reasons a test goes red. Only one of them is a loop.
- Your change is wrong. ← This is the loop. Fix the code. Re-run. Repeat.
The overwhelming majority of failures.
- The test encodes a wrong expectation. Rare, and it is a decision, not a
fix. Say so out loud, explain what the test asserted, why that expectation is
wrong, and what the correct one is. Change the test in its own commit with that
reasoning in the message. Never quietly, never mid-loop.
- The test found a real, pre-existing bug — often in code you did not touch.
Stop the loop and report it. This is the test doing its job. Task 2's
install smoke test may well fail on its first-ever run for exactly this reason.
Burying that under a "fix" is the worst outcome available to you.
A snapshot diff is a question, not a failure. public-surface.snapshot.json
failing means the public API changed. The question is "did I mean that?" If the
diff only adds names — additive, fine, re-record. If it removes or renames one,
you just broke every consumer: add an alias, do not accept the diff.
UPDATE_SURFACE_SNAPSHOT=1 is a deliberate act, not a way out of a red test.
Bound the loop. "Infinite" is a figure of speech; thrashing is real, and it
burns budget while hiding a design problem.
- Same failure three times with three different fixes? Stop guessing. Form a
written hypothesis, add an instrument that would disprove it, run it, and read
the result — do not keep permuting code.
- About to modify a test's assertion? Stop and surface it. That is case 2 or 3,
and it needs a human, not a loop.
- Failure is environmental (network, Daytona sandbox, a flaky port)? Say so, name
the evidence, and do not pretend a re-run is a fix.
Never report a subset as the whole. bun test src/foo.test.ts passing tells
you nothing about the suite. And the exit code is not a safety net — verified
against bun 1.3.14:
| Command | Exit | Trap? |
|---|
bun test src/missing.test.ts | 1 | no — fails loudly |
bun test src -t "no-such-name" | error | no — fails loudly |
bun test some/dir/with/no/test/files | 0 | yes — runs nothing, reports success |
So always finish on the full pnpm --filter @kortix/sdk test, and check the
count against the baseline you measured at the start of this session (see
"Never end a turn without running the gates" above — do not compare against a
number hardcoded in this doc, it drifts). A green run that says Ran 12 tests
is a run you filtered by accident. A green run that says Ran 0 tests is not a
green run.
Then say whether it is shippable
Every response that changes code ends with an explicit verdict. Not a vibe — a
claim you are accountable for:
Shippable to production: YES / NO / NOT YET
- Verified: what you ran, and what it printed.
- Unverified: every surface you did not exercise, and why.
- Risk: what could still be wrong, concretely.
Say NOT YET freely. It is a useful, honest answer, and it is far cheaper than a
confident YES that turns out to be a broken npm install for real users. If a
surface could not be exercised this turn, name it — "the IIFE bundle was never
loaded in a browser" — rather than letting silence imply coverage.
Never write "should work", "looks correct", or "tests should pass". Run it, then
report what happened. If tests fail, say so and show the output.
Tests ship with the change
This is a convention, not a gate. No workflow blocks a PR that changes
packages/*/src without touching a test — so the only thing enforcing it is you
and the reviewer. A behaviour change ships with a test. Formatting, comments, and
renames of non-exported symbols do not need one.
This package is densely covered — match the neighbouring file's style rather than
inventing a harness. Tests live beside the code they test (foo.ts →
foo.test.ts).
Examples are executable documentation
examples/*.ts are typechecked in CI (tsc --noEmit -p examples/tsconfig.json).
They import from ../src/index, not @kortix/sdk, so they resolve without a
published build — but every example's header comment must show the npm import
line a real consumer would write, because that is what people copy.
Examples are also the only place the framework-free claim is demonstrated rather
than asserted. Keep them free of React, DOM, and Node-specific APIs beyond
process.env and console.
Where the real logic goes
Per the root AGENTS.md: hosts are thin. If apps/web, apps/mobile, or
apps/whitelabel-demo needs behaviour that isn't here, it is added here and
exposed through the public surface — not hand-rolled in the host, and not reached
for by importing an internal src/ path. A host importing
@kortix/sdk/src/anything is a bug in both places.