Skip to main content

Twin

A twin is a hand-written clone of one provider’s API. It reproduces the payloads and the behavior behind them: state machines, pagination, idempotency, authentication, rate limits and error envelopes. Nothing reaches the real provider. Twinbay validates a twin by running the provider’s own SDK against the built image in CI. A twin may be incomplete, and it never invents behavior: an operation outside its fidelity answers with an explicit provider-shaped refusal rather than a plausible response. See not modelled. Browse the twins for what Resend and Linear cover today.

Environment

An environment is one isolated instance of up to 25 twins. Each twin inside it has its own hostname, credential, records and request log, and writes made through the provider surface persist as long as that twin keeps running. Each twin runs as a container of its own, created when you ask for it and holding its state in a database inside that container. Nothing is shared: two environments never see each other’s records, and neither do two organizations.
The call returns 202. Twinbay recorded the environment and queued provisioning, which is a container per twin, so wait for each twin with GET /environments/{id}/twins/{twin_id}?wait_for=ready, which holds the answer until it serves rather than being polled. A twin that has not reported ready within two minutes is marked failed, and failure says why.

Twin state

Each twin in an environment carries a desired_state you set (running or stopped) and a state it reports:
Stopping a twin destroys its container, and the records inside it go with it. Starting it again provisions a fresh container in the state the image starts in, so any write your tests made is gone and the credential you collected no longer opens anything. Collect a new key after every start.
The url is the one thing that survives, because a twin’s hostname belongs to the environment rather than to the container behind it. Stop a twin once a suite has finished, and read a start as a new run at the same address.

Provider-shaped credential

Each twin mints its own key in the provider’s format, such as re_... for Resend or lin_api_... for Linear. Collect it with POST /environments/{id}/twins/{twin_id}/credential. Twinbay returns the key on every call for as long as the twin runs, so a run that lost it can read it again. Keys are per twin: the one issued for Resend does nothing against the Linear twin beside it. A key belongs to the container that minted it, so stopping and starting a twin invalidates it and you collect a new one. The credential opens the twin. Your Twinbay API key never travels to a twin, and a twin credential never reaches the Twinbay API.

Records

Records are the twin’s state, readable and writable through the control plane rather than through the provider surface.
The resource is the twin’s own collection name, emails on Resend or issues on Linear. A PUT sends the whole record in fields and replaces what is there, which is how you reach state a provider call would refuse to create.

Time and advancing

Provider lifecycles that take minutes or days in production move when you advance the environment, so a test asserts on a settled state instead of waiting for one:
Each twin defines what a step means. Resend settles queued and scheduled sends, finishes domain verification, and materializes broadcasts. Linear moves each open issue one step along its workflow and auto-archives finished ones. Delivery outcomes stay deterministic, so a hard bounce is a hard bounce on every run.

Request log

Every call a twin serves is recorded: method, path, query string, status code, latency, content_type, and a capture of the request and response bodies.
A body is captured as it was sent — a twin serves data it invented, so nothing in one is hidden or rewritten. Two things happen to it on the way in:
  • Large bodies are capped at 64 KiB, with truncated marking a capture the cap cut short. size reports what the caller sent.
  • text is null where the capture is not text, either because content_type says it is not or because the bytes do not decode. size still reports it.
Headers other than content_type are not recorded. Assert on the log for what a call carried, and on records for what it changed. Neither is a byte-for-byte transcript.

Exports

The log is a window: it is paged, and it is swept once traffic is older than the retention period. An export is a file. One call freezes a slice of an environment’s traffic into a JSONL object that outlives the window, and the download is a link rather than a response held open — which is what a CI job archiving a run, or a dataset built from many of them, actually needs.
All three fields are optional: an empty body exports every twin for all of time. The call answers 202 with the export pending, a worker writes the file, and download signs a 15-minute link once the export is ready. Line one of the file is a manifest carrying the export’s id, the environment, and the slice it was asked for, with null where nothing was asked. Read that first: a filtered export and a whole-environment one are identical in structure, so the slice is what tells a dataset from a wrong dataset. Every later line carries a type — request for a call, ordered oldest first, and record for state a twin held. Bodies are the same capped captures the request log serves. An export never reads past the moment it was asked for, so a window ending in the future stops there, and re-running one that failed rebuilds the same file. A time-sliced export carries no records at all. Records are always current state, never historical, so the state on disk is the state after the window rather than at its end. Each twin in the manifest says which of the three it is: captured, unavailable when the twin was no longer serving, or out-of-slice when occurred_until was set.

Seeds

A twin with no seed starts in the state its image starts in: a working account with very little in it. A seed is the account you want instead — an aged support backlog, a quarter of orders, a ledger of unpaid invoices — described in your own words once and loaded into every environment you start from it. It is the whole starting dataset rather than rows piled on top of the plain one, so an assertion counts what your description asked for.
The call returns 202. Twinbay recorded the description and queued the work; read the seed until its status is ready, then name it when you create an environment:
GET /seeds/suggestions lists the described states Twinbay ships for each twin that can be seeded. Send one back as a prompt, edited or as it stands.

What a seed is, and is not

A language model reads your description once, when the seed is generated, and it only decides two things: the numbers behind the description, and the words that fill the prose columns. Deterministic code decides everything else, so asking for 2,000 orders costs twenty product names rather than two thousand. Nothing about provisioning calls a model. Generation finishes before any environment uses the result, which is why starting an environment from a seed is as fast and as repeatable as starting an empty one. Every row in a seed is written through the twin’s own writing code, so a seed cannot hold a record the twin’s API would have refused to create: a seeded Shopify order has the stock commitment, the fulfillment order and the financial status the twin computes for one, not a guess at them.

Seeded data ages with provisioning

A seed records how old its data is rather than when it was made. “A ticket raised 90 days ago” is 90 days before the environment was provisioned, not 90 days before the seed was generated, so an aging, SLA or incremental-sync assertion reads the same in March and in September. Provisioning with a backdated clock still produces backdated history.

When a seed stops working

A seed is generated against one twin’s storage. Rebuild that twin in a way that changes its storage, and every seed for it reports usable: false and has to be generated again — the description is still there to send back. Seeds for other twins are untouched.
Deleting a seed is refused while an environment or a scenario still names it.

Scenarios

A scenario is a saved starting-state definition: the twins you want and an optional reusable seed for each one. Create it without starting an environment:
Use a twin slug for its default account, or an object with twin and an optional seed ID. A scenario accepts existing seeds, not descriptions that generate new ones. Each seed must belong to your organization, match its twin, be ready and remain compatible with the twin’s current release. Start an environment with scenario instead of twins:
Twinbay selects the current release of each twin and validates the seeds each time you use the scenario. A scenario saves starting data references, not a running environment’s records. You can also set save_as_scenario: true when creating an environment from a twin list to save that definition.
Send name, twins, or both to update a scenario. A supplied twins list replaces the saved list. Updating or deleting a scenario does not change environments you have started from it. Scenarios belong to your organization, and a seed cannot be deleted while a scenario references it.

Not modelled

A twin refuses an operation it does not model in the provider’s own error shape, carrying Twinbay’s name. Read that refusal as a fidelity boundary, not a missing route and not an empty collection. Linear’s twin goes further inside a GraphQL document: a field it has no value for is refused rather than returned as null, because null is a fact about a record and a refusal is a fact about the twin. If your test needs behavior a twin refuses, tell us at hi@twinbay.ai.

Organizations and keys

Environments, scenarios and keys belong to an organization, and every request is scoped to the one it was issued for. Two credentials reach the API: Create a key in the console under API keys, or with POST /organizations/current/api-keys. A key acts with the role of the member who created it, so demoting or removing that member changes what the key can do. Twinbay stores only a digest, so the creation response is the only time the token is readable. Revoke with DELETE /organizations/current/api-keys/{id}.