plimsoll trainer · 04 / client contract

Integrations

There are two ways to plug plimsoll into a product: embed its Go package, or call the daemon from a client in Go, Python or TypeScript. In Go your code cannot tell the two apart: the same interface, the same request and result types, the same floor, meaning the weakest isolation tier (how strong the wall around a run is) that a request accepts. What changes is which process owns the provider, the backend that runs the code; the proof that it is ready; and the permissions a run carries.

Two plugs, one contract

Embed the sandbox package and your process owns everything. Call plimsolld over Connect, an RPC framework that runs over ordinary HTTP, and a daemon owns everything, behind authentication, with an audit line per run. Pick a plug: the code and the list of who owns what follow.

YOUR PRODUCT calls the sandbox.Sandbox interface and reads Result / ProjectResult THE SAME PROCESS sandbox.Build selects the provider EnsureReady proves it · capacity counted here a grant is a Go value you assemble docker daemon or cloud VM key: yours to configure

    The remote plug from Python or TypeScript

    Two more official clients call the same daemon and make the Go client's checks: each states the protocol number (the version of the request format it speaks), recomputes every run record (the daemon's statement of what was sent, what came back and where it ran), compares the returned tier with the floor it sent, follows the chain of records in a session (one sandbox kept open for many calls), and reads the mark that says nothing ran (section 03).

    • Python, from a clone of the repository: pip install ./clients/python (the package is plimsoll-client). Standard library only, Python 3.10 or later, with AsyncClient for programs built on Python's asyncio.
    • TypeScript: npm install @plimsollmark/client. No runtime dependencies, Node 22.18 or later. Its executeCode tool for chat agents is covered in Agent products (INTERNAL · trainer site →).

    None of them follows a redirect. A 307 or 308 answer would otherwise make a client send the request again, the code included, to wherever it pointed, so a redirect is an error. A Go program that passes its own HTTP client with client.WithHTTPClient has to refuse redirects too.

    The pinout

    What a snippet run carries in and what it hands back, on either plug, laid out like a connector's pinout. The two highlighted fields are the ones a product must set itself; every other field has a safe default or is evidence the run produces. A grant, field 3, is permission for a run's code to call listed routes of one API.

    1. sandbox.Request · in
    2. 1CodeThe JavaScript. At most 256 KiB; over that is ErrInvalidRequest before any provider is touched.
    3. 2TimeoutWall clock for the run. The RPC edge caps it at 5 min; a provider may clamp lower.
    4. 3GrantEmbedded: a HostAPIGrant you assemble in memory; nil means no network. Remote: must be nil; a named profile, a grant stored on the daemon under a name, is selected on the client instead.
    5. 4MinimumIsolationThe floor this work needs. Checked against the tier the provider reports before the run starts; a weak daemon refuses with ErrInsufficientIsolation and nothing runs.
    1. sandbox.Result · out
    2. 1ExitCodeHow the code did. Non-zero is a normal result, never a Go error.
    3. 2Stdout, StderrSize-capped. When cut, StdoutTruncated and StderrTruncated say so; no marker is ever added to the bytes themselves.
    4. 3TimedOut, DurationWhether the clock ran out, and how long the run took.
    5. 4Sandbox, IsolationThe provider that ran it and the tier it ran behind. The client compares Isolation with the floor it sent; a mismatch is ErrIsolationEvidenceMismatch.
    6. 5AdviceEfficiency findings the daemon chose to return for a run with a grant. Empty on an embedded provider, because the daemon computes advice, not the provider.

    A project run has the same shape with different fields: Files (path and content, canonical relative paths only), Steps (shell commands, in order, at most 20 steps), Artifacts (the relative paths to bring back, at most 100 artifact paths), the same Timeout, Grant and MinimumIsolation; and back: one StepResult per command that ran, the artifacts that existed, and a typed Outcome. Section 04 runs one. The whole request may hold 200 files and 4 MiB of content.

    Did the code run, and may I retry?

    Every returned error answers two questions, and the two answers are independent. Tap an error to see its Connect code and what a product should do with it. The sentinel errors, fixed error values a caller compares with errors.Is, survive the trip over the network, so errors.Is works on the remote plug too.

    Read "nothing ran" from the mark, not from the error kind. Every refusal raised before dispatch, the moment a checked request is handed to the provider to run, is marked, with a reason, and sandbox.NotDispatchedReason(err) reads it the same on either plug (on the wire it is the NotDispatched error detail). An error without the mark may have run, whatever its kind: the one ErrInvalidRequest a simulation worker returns after its container started is unmarked, and so is every error in the third box.

    Nothing ran · fix and resend

    Nothing ran · retry with backoff

    May have run · not an automatic retry

    Ran · not an error at all

    A project run, step by step

    Files are written into the sandbox, steps run in order and stop at the first failure, and only the artifacts you named that actually exist come back. Choose what happens.

    A failing step is a result: Outcome is completed, the failure sits in that step's ExitCode, and later steps simply never ran. Only a run the provider could not carry out gets a different outcome: setup_failed, timed_out, or protocol_error, each with human context in Detail. Refusals before dispatch and infrastructure failures are returned errors (03), never an outcome.

    Before the feature ships

    Startup checks state an intent. The request floor and the result check are what hold on every call, including the one after the daemon was reconfigured.

    Keep reading