Skip to content

API Integration

This document describes how the Vayu Manager communicates with the Vayu Engine (C++ daemon) via HTTP.

Overview

The app communicates with the engine through: - HTTP REST API: For CRUD operations and request execution - Server-Sent Events (SSE): For real-time load test metrics

All communication happens on localhost:9876 (configurable).

API Client Architecture

┌─────────────────────────────────────────┐
│         React Components                │
│  (RequestBuilder, Dashboard, etc.)     │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│  Hooks + singletons                     │
│  (useEngine, queries/, loadTestService) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│         Services Layer                  │
│  - api.ts (HTTP client)                │
│  - sse-client.ts (SSE client)          │
│  - http-client.ts (fetch wrapper)       │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│         Vayu Engine                     │
│  (localhost:9876)                      │
└─────────────────────────────────────────┘

HTTP Client (services/http-client.ts)

Low-level fetch wrapper with error handling and timeout management.

Features

  • Base URL Configuration: http://127.0.0.1:9876 (from config/api-endpoints.ts)
  • Request Timeout: 30 seconds default
  • Error Transformation: Converts HTTP errors to ApiError with user-friendly messages
  • Query Parameters: Automatic URL encoding
  • JSON Serialization: Automatic request/response JSON handling
  • No Caching: every request passes cache: "no-store", matching the engine's own Cache-Control: no-store on every response (#1507) - nothing the engine answers is valid to replay from Chromium's disk cache

Error Handling

class ApiError extends Error {
  statusCode: number
  errorCode: string
  userFriendlyMessage: string
  response?: any
}

Every engine error body is {"error": {"code", "message"}}, so message is the engine's own text (a validation reason, a not-found) and errorCode is its code - per-status (bad_request, not_found, ...) unless the route names a more specific one. Two fallbacks sit behind that, and both matter: a body in the legacy flat shape ({"error": "..."}, which a pre-#173 engine sends and the sidecar's version can lag the app's) still yields its string as the message, and a body carrying neither falls back to HTTP <status>: <statusText>. response keeps the raw body, which is where per-error detail lives - error.item on a failed bulk import, the provider fields on /oauth2. See the engine's error contract.

Error Types: - isTimeout: Request timeout - isNetworkError: Connection/DNS errors - isDatabaseError: Engine database errors

API Service (services/api.ts)

High-level service layer that wraps HTTP client with domain-specific methods.

Data Transformation

The service handles transformation between frontend (snake_case) and backend (camelCase) formats:

Frontend Format (snake_case):

{
  collection_id: "col_123"
  created_at: "2024-01-01T00:00:00Z"
  elements: []
}

Backend Format (camelCase):

{
  collectionId: "col_123"
  createdAt: 1704067200000
  elements: []
}

Request bodies

body goes to the engine as the discriminated union { mode, content } or - for the two form modes - { mode, fields }, built by buildExecBody (modules/request-builder/utils/execute-mapping.ts) and passed through untransformed. The mode strings are a contract: the engine matches "form-data" and "x-www-form-urlencoded" exactly and reads the content out of fields, so a renamed mode or a flattened content string sends an empty body rather than failing. Disabled rows are sent and dropped engine-side, and the engine writes the Content-Type each form mode implies. A form-data row may be a file part ({type: "file", src, fileName?, contentType?}): the renderer sends the path the user picked - never the bytes - and the engine opens the file at send time. See the engine's body union for the full contract.

API Methods

Health & Configuration

apiService.getHealth(): Promise<EngineHealth>
apiService.getConfig(): Promise<EngineConfig>
apiService.updateConfig(config): Promise<EngineConfig>
apiService.getRequestDefaults(scope?: RequestDefaultsScope): Promise<RequestDefaults>

getRequestDefaults reads what the engine will add to a request that names none of it (issue #1229) - a User-Agent, a negotiated Accept-Encoding, and an opt-in correlation id - as {headers: [{name, value?, generated, configKey?}]}. A row with generated: true carries no value: it is made fresh per transfer. The set is read, never derived: the engine resolves it (its libcurl decides which encodings can even be asked for), so a renderer working it out from the config entries would be a second definition of the same rule. The Headers tab renders it as the read-only "Added by Vayu" group, and a row ticked off there rides the send as disabledDefaultHeaders, which POST /execute and POST /runs both take. None of it is persisted on the request.

scope (issue #1338) selects which send is being asked about: "design" (the default, and the Headers tab's own answer) or "load". The two can disagree - negotiateCompression governs a design send's Accept-Encoding and loadNegotiateCompression governs a load run's - so the load-test dialog reads both and names the difference rather than starting a run whose headers silently do not match what the tab just showed.

apiService.getCookies(): Promise<GetCookiesResponse>
apiService.clearCookies(scope?: { environmentId: string | null }): Promise<ClearCookiesResponse>

The engine keeps one cookie jar per environment for design-mode requests (issue #301); CookiesCard in Settings → General shows and clears them.

Request examples

apiService.listRequestExamples(requestId): Promise<RequestExample[]>
apiService.createRequestExample(requestId, example): Promise<RequestExample>
apiService.deleteRequestExample(requestId, exampleId): Promise<void>

Saved example responses stored against a request (issue #481) - what an import found next to it, and what a mock server will serve. Not read-only any more (issue #588): the response viewer's Save as example keeps the response on screen as one, and the Examples tab removes one. PUT still has no caller and so no endpoint constant - the panel is a viewer, and editing a stored example is its own change. The three consumers are in queries/request-examples.ts (useRequestExamplesQuery, useCreateRequestExampleMutation, useDeleteRequestExampleMutation); both writes settle by invalidating the one list key rather than splicing a row in, since the engine decides the id, the order an append lands on and the stored shape of the row.

A saved example is written with origin: "user", and an imported one keeps the engine's "import" default. Its first reader is the OpenAPI spec sync (#627), which may replace the examples a document produced and must never touch one a person saved - and since #722 the panel reads it too: RequestExample claims the field, an imported row carries an Imported chip, and the delete dialog says what can still bring that kind of row back. The asymmetry the field encodes is a fact about the list, so leaving it invisible made which rows the next sync would replace unpredictable. The rest of the payload is the importers' own mapping, contentType included (the response's Content-Type header verbatim, "" when it stated none), so an app-saved example and an imported one are served identically. No order is ever sent: the engine appends, which is what keeps a restarted mock answering with the same first example.

No transformer, unlike a request row: an example carries no timestamp the app renders and no column that predates a schema change, so the wire shape is the domain shape - minus the order and timestamps the RequestExample type deliberately does not claim, since the list arrives already ordered and no surface displays either. The stored order is the contract, not a suggestion: a mock server answers with the first example of a matched request, so the panel renders the list as received rather than re-sorting it.

Imported examples take a different route entirely: they ride nested on their request item in POST /import/apply (ImportApplyRequestItem.examples), not through this endpoint, so the whole tree still lands in one engine transaction.

Webhook inbox

apiService.listInboxes(): Promise<Inbox[]>
apiService.startInbox(request?: StartInboxRequest): Promise<Inbox>
apiService.stopInbox(inboxId): Promise<Inbox>
apiService.deleteInbox(inboxId): Promise<DeleteInboxResponse>
apiService.updateInboxResponse(inboxId, response: Partial<InboxCannedResponse>): Promise<Inbox>
apiService.listInboxCaptures(inboxId, limit?, offset?): Promise<InboxCapturesResponse>
apiService.clearInboxCaptures(inboxId): Promise<ClearInboxCapturesResponse>

An inbox records the requests sent to it and answers a canned response (issue #480); modules/inbox/ is the surface. updateInboxResponse is a merge-patch - an omitted field keeps what the inbox is serving - and the live capture stream (INBOX_LIVE) is a plain EventSource rather than SSEClient, which maps load-test metrics specifically. Captures arriving on that stream are merged into the listInboxCaptures cache, so there is one list. deleteInbox is the stronger of the two lifecycle calls: stopInbox frees the listener and leaves the record and its captures readable for the life of the engine process, while a delete takes both (issue #553). Its mutation removes the captures cache entry rather than invalidating it - an invalidation would refetch an id the engine now 404s. Inbox.captureCount is what a delete would destroy, and is what the confirmation is worded from. clearCookies distinguishes three cases the way the engine does, and they are not interchangeable: omitted clears every jar, { environmentId: null } clears only the jar used when no environment is selected, and an id clears that environment's. It reaches DELETE /cookies with the parameter absent, present and empty, or present with the id, respectively.

OAuth 2.0 mock issuer

apiService.listMockIssuers(): Promise<MockIssuer[]>
apiService.startMockIssuer(request?: StartMockIssuerRequest): Promise<StartMockIssuerResponse>
apiService.updateMockIssuer(issuerId, update: UpdateMockIssuerRequest): Promise<MockIssuer>
apiService.stopMockIssuer(issuerId): Promise<StopMockIssuerResponse>

A local issuer that mints HS256 tokens on demand (issue #479); the Services drawer (modules/services/) is the surface, added in #502 - before it these routes had no client but curl and the MCP tools. Two asymmetries the surface has to respect: startMockIssuer answers with the URLs and the signing key only, not the full record, so the list is refetched rather than patched; and a stopped issuer leaves the list altogether, unlike an inbox, which stays listed with running: false. updateMockIssuer accepts only expiresInSeconds, failureMode and slowMs - a port, client list or claim set cannot move under a bound listener and the engine refuses one with a 400 rather than half-applying it.

Collection mock server

apiService.listMockServers(): Promise<MockServer[]>
apiService.startMockServer(request: StartMockServerRequest): Promise<MockServer>
apiService.stopMockServer(mockId): Promise<StopMockServerResponse>
apiService.listMockServerRoutes(mockId): Promise<MockServerRoute[]>

A loopback listener answering a collection's saved example responses on the paths its requests describe (issue #481 phase 2). collectionId is the only required field; latencyMs and errorRatePct are the injection knobs, and an out-of-range value is a 400 rather than a clamp.

Two surfaces, deliberately split. CollectionDetail/MockServerControl is the only one that can start one, because a mock needs a collection and the Services drawer has none selected; the drawer's Mock servers group lists and stops whatever is running, wherever it came from. Both read the same polled list, so a mock started in one is visible in the other within a poll.

The knobs are sent from that control's options dialog and nowhere else (StartMockServerDialog, issue #570), bounds-checked against mock-server-options.ts before the request leaves - the engine's 400 names the field but not the range. There is no update verb: latencyMs and errorRatePct are read per response and so could change under a running mock, but a run pointed at one has to be able to say which configuration produced its numbers, so they are frozen at start like the route table.

listMockServerRoutes is polled, at MOCK_ACTIVITY_POLL_INTERVAL_MS, even though it carries staleTime: Infinity: the route table's shape is a snapshot taken when the mock started and a running mock does not reload the collection, but each route's hits changes live as traffic arrives, so the poll is what keeps that count current. staleTime: Infinity only means mounting a second reader of the same query does not force a redundant fetch - it does not stop the interval firing. Stopping drops the record engine-side - unlike an inbox, which stays listed with running: false - so the mutation removes the routes cache entry instead of invalidating it, which would refetch an id the engine now answers 404 for.

Create vs update

For collections, requests and environments the engine splits the write verbs: POST /<resource> creates and never updates; PUT /<resource>/:id updates and answers an unknown id with 404. They are not interchangeable, so apiService keeps one method per verb:

  • createX(data) posts the object to the collection path with id stripped (withoutId in api.ts). The engine assigns every id and answers a create carrying one with a 400, and the Create*Request types declare id?: never - but TypeScript only excess-property-checks object literals, so a call site that spreads a whole record (a duplicate flow, a restored tab) would slip one through. The strip is what actually holds. (Import used to be the exception, pre-assigning ids to wire parentId / collectionId across a tree before anything was persisted; it sends one applyImport call now, see below.)
  • updateX(data) takes data.id, puts it in the path, and sends the rest of the object as a merge-patch body - an omitted field keeps its stored value, an explicit null resets it to the default. The id is not repeated in the body; a body id disagreeing with the path is a 400.

The full contract, including the null-vs-absent table and which fields have no default, is in engine/api-reference.md under "Resource writes". src/services/api.write-verbs.test.ts pins the method and path of every one of these calls - a regression here is invisible at every other layer, because the payload shape does not change.

Collections

apiService.listCollections(): Promise<Collection[]>
apiService.createCollection(data): Promise<Collection>   // POST /collections
apiService.updateCollection(data): Promise<Collection>   // PUT  /collections/:id
apiService.deleteCollection(id): Promise<void>

A collection carries dataSchema - the data contract it declares (issue

599): {columns?: string[], declaredAt?: number, fileName?: string}, where

{} means it declares none. CollectionTransformer normalizes it field by field rather than casting, because a row can come from an engine that predates the column and every reader treats columns as a string array; use hasDataContract(schema) instead of hand-rolling the check, since a cleared contract is {} and not undefined.

On updateCollection the field is CollectionDataSchema | null, like parentId: the engine reads absent as "keep the declared contract" and an explicit null as "reset to none", so the Data tab's Clear is only expressible as a null that survives to the wire. The rows behind the schema are never sent by these calls at all - they ride only the POST /runs payload, and are persisted by neither side.

A collection also carries openapi - the spec document it is bound to (issue #637): {specId?, specHash?, syncedAt?}, where {} means bound to nothing. It is normalized field by field for the same reason dataSchema is, and hasSpecBinding(binding) is the check to use - a collection that was bound and then unbound holds {}. On updateCollection the field is CollectionOpenApiBinding | null, and the Spec tab's Unbind is that null.

Specs

apiService.createSpec(data): Promise<SpecDocument>       // POST /specs
apiService.getSpec(id): Promise<SpecDocument>            // GET  /specs/:id
apiService.getSpecMeta(id): Promise<SpecDocumentMeta>    // GET  /specs/:id/meta
apiService.syncSpec(payload): Promise<SpecSyncResponse>  // POST /specs/sync
apiService.describeSpec(payload)                         // POST /specs/describe
apiService.matchSpecOperations(payload)                  // POST /specs/match
apiService.diffSpec(payload): Promise<SpecDiffResponse>  // POST /specs/diff
apiService.bindSpec(payload): Promise<SpecBindResponse>  // POST /specs/bind
apiService.exportSpec(payload): Promise<SpecExportResponse> // POST /specs/export

describeSpec is the read that replaced the last parse this app did of a spec document (issue #869). It sends the picked bytes and gets back what they are - the dialect ("OpenAPI 3.0", "OpenAPI 2.0 (Swagger)"), info.title, and the identities the document declares - and the Spec tab paints its card from that and hands those identities to matchSpecOperations. It parsed the file itself until then, with services/openapi/spec-operations.ts, while the bind derived the same identities engine-side from the same bytes: two readers of one document, which could preview one pairing and commit another. A document that is not a contract, or that will not read at all, is a 400, and the tab prints the engine's sentence.

matchSpecOperations is a read (issue #761): it pairs the requests in a collection's subtree with the operations a document declares and writes nothing, which is what lets the Spec tab show the counts before the user commits to a bind. The pairing rule lives in the engine (core/operation_match.hpp) rather than in the renderer, so binding an existing collection is reachable from anything that is not the Spec tab - an agent over MCP first among them. The payload names the collection and sends the operations; it does not send the requests, because an OpenAPI import files them under tag sub-collections and the engine gathers the whole subtree itself.

exportSpec is a read for the same reason and sends even less (issue #855): a collection id and a format. The engine reads the subtree, each request's stored examples and the bound document - which is the point, since a bound export patches those stored bytes - and answers with the finished text, a file name and the notes the dialog prints. A 409 there is a binding whose document is not stored, or stored bytes that will not read as OpenAPI; the renderer prints the engine's sentence rather than falling back to a skeleton.

Neither index is sent on any write that stores a document - not by createSpec, syncSpec or the specs section of importCollection below. The engine reads the document it is storing and derives the operations index (#629, moved by #853) and the responseSchemas index (#628, moved by #860) from those very bytes, so sending either is a 400, the way a supplied hash is; a sync that forgets one can no longer turn coverage or response validation off for a collection that had it. The renderer stamps no identity of its own any more either (issue #877): an import is parsed by the same reader, so the identity on a request and the identity in the index come off one walk rather than two that had to be pinned to each other.

Create and read-by-id only, plus the two writes that move a binding. syncSpec is one (issue #655): it stores the re-fetched document, points the collection at it and applies the created, updated and deleted requests in a single engine transaction, because a sync that stopped halfway would leave a collection bound to a document its requests do not reflect. The renderer decides what to send (services/openapi/spec-apply.ts) and never sequences the writes itself.

diffSpec is the read that the Sync section's Check is (issue #854). It sends the collection and the re-fetched bytes and nothing else: the engine walks the collection's subtree itself and reads the bound document from the binding, because the three-way userTouched flag - the request holds neither the new document's value nor the bound one's - is worth nothing if a caller can supply the previous side. It writes nothing, and the answer carries, per entry, the draft an apply would write, so spec-apply.ts builds a POST /specs/sync payload out of it without reading a document at all. The comparison used to be services/openapi/spec-diff.ts, which is gone.

Each entry also carries what a sync with no ticks would do to it - safe, and safeFields on a changed request (issue #871). defaultSelection reads those rather than deriving them from userTouched, because the rules behind them are the ones whose silent failure destroys a person's work and they now have one author: core::safe_spec_apply, which POST /specs/sync's policy: "safe" applies for a caller that states no rows at all. syncSpec still sends explicit rows from here, since the ticks a person changed are not a policy.

The one field of that draft spec-apply.ts does not send back is examples (issue #869): an update carries examples: true - refresh this request's imported examples - and the engine writes the responses the document it is storing documents. A created request states none at all, for the same reason. Echoing the rows back was a round trip through the client of an answer the engine had already given, and it let a payload state an example for a response no document describes.

bindSpec is the other (issue #862), and it decides even less: the payload is a collection and a document, with no pairing in it. Binding used to be three calls made in order from useBindSpecMutation - store the document, move the binding, then stamp the matched requests and clear the stale ones - and both halves of that were defects waiting to happen: three writes are three places to stop, and the clearing half is a list a caller can forget, which is exactly what issue #718 was. POST /specs/bind commits all of it or none of it and works the pairing out from the bytes it stores. matchSpecOperations above is still what the Spec tab previews with; it is not a payload for this call, and the bind matches again over the same rule.

A document is immutable - a changed spec is a new document and a moved binding, which is what keeps a run's specHash stamp meaningful - and the hash is computed engine-side on the bytes it stored, never here. There is no delete call: unbinding is a PUT /collections/:id, and the document stays for whatever else binds it. Both reads are cached with staleTime: Infinity, because the row behind a given id cannot change.

Describing a document and reading one are two calls (issue #712). getSpecMeta answers sourceUrl, fetchedAt, hash and contentBytes and nothing else; getSpec sends content and both extracted indexes with it - up to maxSpecDocumentBytes, which is 12 MB for Stripe's published spec. The rule for choosing is what the caller does with the answer:

Caller Read Why
The Spec tab's card (useSpecMetaQuery) meta It paints a source, a date and a size; it never renders the document
The Sync section's Check (useSpecMetaReader) meta, on the click Since #854 it needs only sourceUrl - where to re-fetch from - because the engine compares its own stored bytes
The import dialog's bound-spec match (useBoundSpecReader) full, on the action It compares the document itself

Export needs neither read any more (issue #855): POST /specs/export assembles the document engine-side from the bytes it already stores, so the renderer asks for the finished text and never for the spec.

Reading the full document on tab open is the thing this split removed: a first open of a bound tab transferred the whole spec to paint two fields, on a query that then sat in cache forever. A reader that needs the text still asks for it - on the action that needs it, where the wait is something the user started.

Requests

apiService.listRequests(params?): Promise<Request[]>
apiService.getRequest(id): Promise<Request>
apiService.createRequest(data): Promise<Request>         // POST /requests
apiService.updateRequest(data): Promise<Request>         // PUT  /requests/:id
apiService.deleteRequest(id): Promise<void>

A request carries specOperation - which operation of the bound spec it is (issue #637): {operationId?, method, path}, where path is the document's templated path and not the URL the request sends. The engine serializes null for a request that names none, and RequestTransformer turns that into an absent key; on updateRequest the field is SpecOperation | null, so stamping and clearing an identity are the same verb.

A request also carries methodSource - which app setting last wrote method, still unclaimed by the user (issue #1505): MethodSource ("graphql", the GraphQL body mode's auto-POST) or absent. Same null-vs-absent rule as specOperation: the engine serializes null for no marker, RequestTransformer turns that into an absent key and drops a value it does not recognize, and updateRequest's field is MethodSource | null, so leaving GraphQL - or picking a method by hand - clears the marker the same way.

Trash

apiService.listTrash(): Promise<ListTrashResponse>              // GET    /trash
apiService.restoreTrashEntry(id): Promise<RestoreTrashResponse> // POST   /trash/:id/restore
apiService.purgeTrashEntry(id): Promise<PurgeTrashResponse>     // DELETE /trash/:id

What deleteCollection and deleteRequest now do is a soft delete (issue

988): the engine stamps the row instead of removing it, and these three read

that stamp back. No transformer on any of the three - a trash entry is the engine's own summary of a deleted row (a name, a stamp and two counts), not a stored record the app reshapes, so the wire shape is the only shape.

listTrash returns roots only - the row a user actually asked to delete, never a descendant the same cascade took with it. A deleted collection's whole subtree is represented by that one root entry, with collections / requests counting what that delete took along.

restoreTrashEntry puts one deleted root back, with everything the same delete took, and answers with the entry plus restored: true and reparentedToRoot - true when the collection's parent is gone or itself in the trash, so the engine clears its parent and returns it as a tree root instead. It rejects with a 404 for an id the trash does not hold (a live row, or one already purged) and a 409 for a request whose collection is itself deleted or gone - a request has no root to land on the way a collection does, so the engine's message names the collection to restore first; surface ApiError.message rather than inventing wording for it.

purgeTrashEntry is the hard purge the soft delete deferred - it destroys the row for good, with its whole subtree, and there is no undo. It answers with the entry plus purged: true, or a 404 if the trash does not hold that id.

The full contract - request/response bodies, the restore's cohort and re-parent rules, and the startup retention sweep - is in engine/api-reference.md under Trash.

Reorder

apiService.reorder(data: ReorderRequest): Promise<ReorderResponse>  // POST /reorder

The write path behind a drop. One call repositions any number of collections and requests, and the engine applies the whole batch in one transaction - so a drop that displaces N siblings is one round trip, not N updateRequest calls that can half-land and race a concurrent create into the middle of their range.

The payload carries moves (each row's new order, plus parentId / collectionId when it changes owner) and normalize (scopes to renumber dense 0..n-1 in display order first, for a collection whose rows all predate explicit orders). modules/collections/reorder-math.ts computes both from the sibling lists the tree is already showing; the full contract is in engine/api-reference.md under POST /reorder.

Unlike the other writes, the response is read: it is the rows as written, and useReorderMutation settles its caches on them so a normalization the engine performed shows up without waiting for the refetch.

Environments

apiService.listEnvironments(): Promise<Environment[]>
apiService.getEnvironment(id): Promise<Environment>
apiService.createEnvironment(data): Promise<Environment> // POST /environments
apiService.updateEnvironment(data): Promise<Environment> // PUT  /environments/:id
apiService.deleteEnvironment(id): Promise<void>

Global Variables

apiService.getGlobals(): Promise<GlobalVariables>
apiService.updateGlobals(variables): Promise<GlobalVariables>

Import

apiService.importFetch(url, maxBytes?, onProgress?): Promise<ImportFetchResponse> // POST /import/fetch
apiService.applyImport(payload): Promise<ImportApplyResponse>      // POST /import/apply

importFetch's maxBytes is the largest response that fetch may read. The engine has no format to derive one from - the route proxies Postman and Insomnia exports as well as OpenAPI documents - so the caller states it: the spec paths ($ref bundling, spec re-fetch, the Spec tab's URL box) pass the live maxSpecDocumentBytes from useSpecDocumentLimit, and the format-agnostic import URL box passes nothing, leaving the engine's transport ceiling. Over the bound is a 413 whose message names it, raised while the body is arriving rather than after it has been buffered whole.

importFetch's third argument is what asks the engine to stream the download (issue #882): with an onProgress callback the call goes through httpClient.stream, which sends Accept: text/event-stream and reports {received, total} as the bytes land - total being null whenever the upstream declared no Content-Length. Without it this is the buffered POST it has always been, which is what $ref bundling and spec re-fetch want: they have nowhere to draw a bar and no reason to pay for a stream.

Two things httpClient.stream does that request cannot. Its timeout is an idle one, so a 10 MB document arriving steadily is no longer racing proxiedRequestTimeoutMs - what is bounded is the stall, not the transfer. And a response that comes back as buffered JSON yields one buffered message instead of failing, because the app and the engine sidecar are not updated together and an older engine answers this request the way it always has.

importFetch takes a fourth argument, an AbortSignal, and it is what actually stops a download (issue #893). Abandoning the iteration releases the stream, but a caller that is not itself iterating - a dialog closing while importFetch sits in the loop on its behalf - has no way to reach that, so 0.22.0 shipped a transport that could cancel and a dialog that never asked it to. An abort raises an AbortError, deliberately distinct from the idle stall's Request timeout: both end in an AbortController, and a deliberate cancel reported as a timeout is an error banner about a failure nobody had.

applyImport sends a whole parsed import - collections, requests, environments and spec documents - in one atomic call. Items reference each other by opaque tempIds and the engine returns the temp-id -> real-id idMap; a rejected payload persisted nothing, so there is nothing to roll back. An OpenAPI import puts the document in the specs section and binds its root collection with openapi.specTempId, so the spec, the binding and every request's specOperation land in the same transaction - specs: [] is sent for every other format rather than omitted, so the payload is one shape. Imported globals are not in this payload: they are a singleton written through updateGlobals after the apply succeeds. See import-collections/README.md for the pipeline and engine/api-reference.md for the contract.

Execution

apiService.composeRequest(data): Promise<ComposedRequest>
apiService.executeRequest(data): Promise<SanityResult>
apiService.startLoadTest(data): Promise<StartLoadTestResponse>
apiService.startScenarioRun(data): Promise<StartLoadTestResponse>

startLoadTest and startScenarioRun are the same POST /runs endpoint and the same 202 {runId} answer - the payload is what selects the executor. A scenario states its work as an ordered collection ({scenario: {source: "collection", collectionId, recursive?, iterations?, data?}}), so it carries no method/url, and its iteration count lives inside the block rather than beside a load-test mode. data is the parsed rows of a data file (services/data-files/), sent inline because the engine never opens a file; iterations is omitted when the user left it blank, so the engine's "absent means one pass per row" rule stays in one place. Both send allowScriptRequests, for the same reason: every step runs the scripts a Send of that request would run. The engine resolves the whole plan before answering, so an empty collection, a step that will not compose, or a plan over maxScenarioSteps is a 400 with no run row created - a failed start leaves nothing to clean up.

startLoadTest takes rows too, as a top-level data array (issue #993): the load dialog's file picker parses the file and sends the same array it previewed, and the engine binds one row per request the run sends. Omitted when no file was picked - a present-but-empty array is refused engine-side, so "no data set" has to be the absent key rather than [] - and never sent beside a scenario block, whose rows are scenario.data and bind per iteration.

failOnSchemaError rides the design-mode payload beside environmentId, from the dialog's Fail steps on schema errors switch (issue #720), and is omitted when off: the engine defaults it to false, so absent already says what the user asked for, and only a run that wanted the gate carries the key into its stored snapshot - where SampledSchemaValidation reads it back to say so. It is never sent on a load payload, whose executor validates after the run has drained and cannot demote a step on it.

Adding a load mode beside the block makes it a scenario load run (issue

357): the same plan, driven by concurrency virtual users on the event loop.

The presence of mode is the whole discriminator, so a design-mode payload must carry none at all - not a falsy one - and RunCollectionDialog therefore spreads the load fields in rather than always sending them. constant_rps, and any non-zero rps/targetRps on any mode, is a 400: an open-loop arrival rate over a multi-step sequence is an arrival-rate executor the engine does not implement, and it is refused rather than quietly run closed-loop. Such a run's type is load, so it streams metrics ticks and not step events - the caller must attach loadTestService, not scenarioRunService.

A scenario run also takes a top-level elements override (issue #1495, wired into RunCollectionDialog by issue #1552): {timers?: "asConfigured" | "off", scripts?: "asMarked" | "allInline" | "allDeferred", includeScriptTime?: boolean}, documented in full at api-reference.md. It rides beside mode/scenario, never inside the scenario block, and RunCollectionDialog sends only the fields the user changed from the engine's own default - the same omitted-when-default rule failOnSchemaError follows. timers is wired to timer.pacing/timer.think end to end and applies to a design-mode run exactly as it does a load run, so the dialog offers that control regardless of Load test; scripts picks whether a script.* element runs inline on the event-loop worker or stays deferred to the post-run replay, and has no effect on a design-mode run (which already runs every script inline), which is why the dialog offers that one control only once Load test is on. Since issue #1594 the single-request startLoadTest payload has an elements attachment point too - its own step-level elements ride under requestElements instead (a distinct key, because this endpoint's own elements is the override block, not a script source) - and LoadTestConfigDialog sends the same elements: {scripts} override there once the user changes the dialog's Scripts control from its "asMarked" default, the one control the single-target payload's own preRequestScript warning needs: "allInline" is what makes a pre-request script actually reach the wire under load, since an unmarked one otherwise never runs on this path either. LoadTestConfigDialog offers no Timers twin, but not because the engine ignores the override there - elements.timers applies to a single target's own timer.think exactly the way it does to a scenario step's - the dialog simply has no timer control of its own on this path to pair it with yet.

What comes back for one differs in two places worth knowing. GET /runs/:id returns the resolved manifest in place of the block that was sent ({source, collectionId, recursive, iterations, dataRowCount, steps[]}, each step {index, requestId, name, method, url} with the stored url, never a composed one) - that is what the run tab's context bar reads, through run-scenario.ts. The paginated GET /runs list row cannot carry the manifest and instead carries summary.scenario ({collectionId, iterations, recursive, stepCount}), present on any row whose snapshot carries a scenario block - so a scenario load run gets it too. The history row reads it because a run whose work is a sequence has no url or method for the ordinary row to show, and that is true of both executors.

A scenario load run's report carries the per-step breakdown the design-mode runner has no need for: scenario.steps[] ({index, name, requestId, method, executed, errors, latency:{min,p50,p95,p99,max}}) plus virtualUsers and iterationsAbandoned. It stores no per-step results rows, so that array is the only per-step record such a run keeps.

A step also carries tests ({sampled, passed, failed}) when its own post-request script was replayed against that step's sampled responses - the deferred per-step validation. The key is absent for a step that asserted nothing or whose script drew no sample, which is not the same claim as zero failures, so the table shows a dash there rather than a 0.

A design run's list row carries one thing the detail route says at greater length: resultSummary ({statusCode, latencyMs}), the outcome of its single exchange. GET /runs/:id attaches the whole result instead, trace and bodies included, which is why the list carries the two numbers rather than that - and why the context bar's Recent sends section is one list call and no report fetch. Load and collection runs carry no resultSummary: their results are unbounded.

composeRequest (POST /compose, issue #226) resolves {{variables}} and inherit auth engine-side and returns the payload the other two accept unchanged - every send site composes first, so nothing is interpolated twice.

GraphQL schema introspection is a send site too (lib/graphql/introspect.ts, issue #228): it composes the endpoint, overlays the introspection query onto the composed url / headers / auth, and executes that - which is how an endpoint whose credentials live in the Auth panel gets introspected at all. It sends the composed request's auth but neither its body nor its script parts.

A Send-with-row carries one extra field (issue #601): data, the row the UrlBar's caret picked, added beside the composed payload rather than passed through POST /compose. {{data.*}} survives composition by design, so the tokens are still written when /execute binds them against the row; composing the row in would be composing twice. Both send handlers take it - buffered and streaming - because the engine binds a row on either path, and a row silently dropped on one of them is the written-but-never-read defect. An ordinary Send passes no argument at all, so its payload is byte-identical to what it was.

The row's names do reach composition, though (issue #1007): composeForSend sends dataColumns: Object.keys(row) on POST /compose, because a bare {{username}} is a name the scopes can answer and composition has to be told to leave it for the bind instead. Names only - a value there would be this row's value written into a payload composed once - and absent for an ordinary Send, so that path is still byte-identical. LoadTestConfigDialog carries the same field for a run given a data file (config.dataColumns, the picker's parsed column list rather than a re-union of the rows' keys), and a collection run sends none: the engine composes those steps itself and fills the set from the run's own rows.

It is the only send site that sets transient: true (issue #382), because it is the only one the user did not initiate. The engine then runs it in full and records nothing: no History entry, no result trace holding the credentials composition resolved, and no retention prune evicting a real run. It also carries the target's environmentId onto the execute payload, which is what scopes the engine's cookie jar - without it a cookie-session endpoint answered real requests and failed introspection alone. Every other send site omits the flag and is recorded as usual; see api-reference.md.

Run Management

// Paginated, filtered history (newest first). Rows carry a compact `summary`
// (url/method/mode/duration/concurrency/comment), not the full configSnapshot.
apiService.listRuns(params?: RunListParams): Promise<RunListResponse>
// Every page as a flat list (Settings' count + clear).
apiService.listAllRuns(params?): Promise<Run[]>
apiService.getRun(id): Promise<Run>            // full configSnapshot
apiService.getRunReport(id): Promise<RunReport>
// Response headers/bodies captured for a run's samples. Its own request, not
// fields on the report - see below.
apiService.getRunSamples(id, { limit?, offset? }): Promise<RunSamplesResponse>
apiService.stopRun(id): Promise<StopRunResponse>
apiService.deleteRun(id): Promise<void>

deleteRun on a run that is still in progress stops it engine-side first, so it takes as long as the stop does, and it rejects with a 409 if the run's worker has not finished writing in time - nothing is deleted in that case. Callers must handle that rejection: HistoryList turns it into a toast telling the user to retry, and Settings' Clear run history already counts per-run failures through Promise.allSettled. The wording of that toast is the caller's, keyed off statusCode, rather than the engine's message - which is a caller's choice now that httpClient reads the message on every error shape (issue #173), not a constraint.

Captured response bodies are fetched separately, and lazily. A load run stores the response headers and body for its failures, its slow outliers and a few exemplars of each status code; GET /runs/:id/report deliberately does not carry them, because that endpoint loads and JSON-parses every result row for the run on each fetch and the dashboard polls it. useRunSamplesQuery(runId, enabled) (queries/runs.ts) wraps getRunSamples and is enabled only once a reader expands a sample - passing true unconditionally would reintroduce exactly the cost the split exists to avoid. It returns a Map keyed by resultId, joined against report.results[].id.

Two surfaces consume it - the dashboard's Sampled Requests (RequestResponseView) and the history Samples tab - and both render CapturedResponseNotice (truncated / dropped for budget / binary) and CapturedDataWarning (the run stored responses verbatim, including anything credential-shaped). Both notices live in components/shared, so the wording exists once rather than twice.

A sample carries the response side only, so its request headers are the composed ones. GET /runs/:id/samples returns response headers and body; what a sample viewer shows for the request comes from the run's composed request, not from a sent record, because the load transport keeps none. So a sampled capture can differ from the wire in the two ways the design-mode sentHeaders record exists to state (issue #664): an enabled header whose value is empty is listed although libcurl dropped it, and the two the engine derives at send time - the body-implied Content-Type and the default User-Agent - do not appear. For load samples this is recorded as permanent (issue #677 item 7): which completions are sampled is decided when they finish, so a record for the few that are kept would have to be built for every transfer. Design-mode traces store sentHeaders and do not diverge.

A sample whose transfer was a stream also carries response.events (issue

657) - {items, totalEvents, eventsTruncated}, parsed engine-side out of the

stored text/event-stream body. Both surfaces render it through the shared ResponseEvents, the component the request builder's Events tab uses, so a sampled stream reads the same way a sent one does. Absent - not an empty node - for every sample that did not stream, which is what gates the tab.

Scripting

apiService.getScriptCompletions(): Promise<ScriptCompletionsResponse>

OAuth 2.0

apiService.fetchOAuth2Token(data): Promise<OAuth2TokenResponse>        // POST   /oauth2/token
apiService.getOAuth2TokenStatus(cacheKey): Promise<OAuth2StatusResponse> // GET  /oauth2/token?key=
apiService.clearOAuth2Token(cacheKey): Promise<void>                   // DELETE /oauth2/token?key=

// Interactive Authorization Code flow (engine-hosted loopback + PKCE)
apiService.startOAuth2Authorize(data): Promise<OAuth2AuthorizeStart>
apiService.getOAuth2AuthorizeStatus(attemptId): Promise<OAuth2AuthorizeStatus>
apiService.completeOAuth2Authorize(attemptId, callbackUrl): Promise<OAuth2AuthorizeStatus>

These back the OAuth 2.0 auth editor. TanStack Query wraps the non-interactive ones in queries/oauth.ts (useOAuth2TokenStatusQuery - polls status ~30s; useFetchOAuth2TokenMutation, useClearOAuth2TokenMutation). The token cacheKey is computed client-side by services/oauth/cache-key.ts, byte-identical to the engine so the app and engine agree on cache slots without a round-trip. The interactive flow is orchestrated in services/oauth/authorize.ts (opens the system browser or an embedded Electron window, then polls the engine).

HttpClient.delete takes an optional params argument so the token-clear call can pass ?key=.

SSE Client (services/sse-client.ts)

Server-Sent Events client for a run's live stream - load-test metrics, and a collection run's per-step progress.

Features

  • Single endpoint: Connects to /runs/:runId/live. The engine retains a replayable tick topic, so the client connects immediately after POST /runs with no attach race - it replays from offset 0 and tails to the complete event (even for sub-second runs).
  • No custom reconnect loop: The engine sends an explicit complete event at normal run end, so a CLOSED readyState is treated as terminal. Transient CONNECTING errors are left to the browser's built-in EventSource retry. At run end the app converges on the stored report (GET /runs/:id/report) rather than reconnecting to the stream.
  • Event Handling: metrics events, step events, monitor events, plan events, complete event, error handling
  • One client, and a hand-off when it changes hands: the client is a singleton, so a second connect takes the socket from whoever held it. That subscriber's onSuperseded runs first, before the new stream opens, so it can give up its wake lock and its OS progress claim (issue #1417). onClose is not used for this: a close says the run ended and the stored report is worth fetching, where a takeover says only that the app stopped watching a run the engine is still executing. disconnect() tells nobody - a subscriber hanging up on itself, and a stream that ended on the engine's complete frame, are not superseded by the next run to start
  • Metrics Parsing: mapSseMetrics() transforms the engine's camelCase blob to the frontend LoadTestMetrics shape (includes drops, queue-wait, percentiles, bytes, status-code map)
  • Step Parsing: parseStepEvent() narrows a scenario run's step payload and returns null for one it cannot read. A malformed event is dropped, never defaulted - the step list keys on (iteration, stepIndex), so a defaulted 0:0 would collide with the real first step's row rather than merely say nothing.
  • Monitor Parsing: parseMonitorEvent() narrows a monitor frame the same way and returns null for one it cannot read. A sample defaulted to timestamp: 0 would join onto the very start of the run's timeline and draw a reading at a moment it was never taken; individual non-numeric entries are dropped, because the rest of the scrape is still real data.

Usage

sseClient.connect(
  runId: string,
  onMessage: (metrics: LoadTestMetrics) => void,
  onError: (error: Error) => void,
  onClose: (status: SSETerminalStatus) => void,   // how the run ended, or null
  onStep?: (step: ScenarioStepEvent) => void,     // scenario runs only
  onMonitor?: (sample: MonitorSample) => void,    // runs with a `monitor` block only
  onPlan?: (plan: ScenarioRunPlanEvent) => void,  // scenario runs only
  onSuperseded?: () => void                       // another run took the client
);

sseClient.disconnect();
sseClient.isConnected(): boolean

Event Types

  • metrics: Real-time metrics update (JSON payload). Load runs only - a scenario run's work is sequential, so a per-tick aggregate would describe one request at a time.
  • step: One step execution of a scenario run - {iteration, stepIndex, name, outcome, statusCode, latencyMs}. Listened for only when onStep is passed, since a load run never emits one.
  • monitor: One scrape of the run's monitored endpoint - {timestamp, series}. Listened for only when onMonitor is passed. Interleaved with metrics ticks on one id space, so Last-Event-ID resume replays both in the order they happened.
  • complete: The run reached a terminal status
  • error: Connection error (triggers reconnection)
  • open: Connection established

API Endpoints (config/api-endpoints.ts)

Centralized endpoint configuration:

export const API_ENDPOINTS = {
  BASE_URL: "http://127.0.0.1:9876",

  // Health & Config
  HEALTH: "/health",
  CONFIG: "/config",
  // What a send adds on its own, resolved from config by the engine.
  // `?scope=design|load` selects which send - see getRequestDefaults above.
  REQUEST_DEFAULTS: "/request-defaults",

  // Collections
  COLLECTIONS: "/collections",
  COLLECTION_BY_ID: (id: string) => `/collections/${id}`,

  // Requests
  REQUESTS: "/requests",
  REQUEST_BY_ID: (id: string) => `/requests/${id}`,

  // Batch reorder for both entity kinds - one drop, one call, one transaction
  REORDER: "/reorder",

  // Cookie jar - GET reads every scope, DELETE clears one or all
  COOKIES: "/cookies",

  // Workspace backup - one VACUUM INTO snapshot into `backups/` beside the
  // database, with retention. A verb path for the reason the inbox uses one:
  // the engine takes a snapshot and the file is not a row anything reads back.
  // There is no restore counterpart on purpose - restoring is a manual copy
  // with the engine stopped, which is why the card prints the path.
  WORKSPACE_BACKUP: "/workspace/backup",

  // Execution
  EXECUTE_REQUEST: "/execute",
  START_LOAD_TEST: "/runs",

  // OAuth 2.0
  OAUTH2_TOKEN: "/oauth2/token",
  OAUTH2_AUTHORIZE_START: "/oauth2/authorize/start",
  OAUTH2_AUTHORIZE_COMPLETE: "/oauth2/authorize/complete",
  OAUTH2_AUTHORIZE_STATUS: (id: string) => `/oauth2/authorize/${id}`,

  // Runs
  RUNS: "/runs",
  RUN_BY_ID: (id: string) => `/runs/${id}`,
  RUN_REPORT: (id: string) => `/runs/${id}/report`,
  RUN_STOP: (id: string) => `/runs/${id}/stop`,
  // Captured response headers/bodies, fetched only when a sample is expanded
  RUN_SAMPLES: (id: string, limit: number, offset: number) =>
    `/runs/${id}/samples?limit=${limit}&offset=${offset}`,

  // Webhook inbox - engine-hosted listener that records what is sent to it.
  // START is a verb path: an inbox lives for the engine process, so the
  // POST-creates/PUT-updates split does not apply to it.
  INBOX: "/inbox",
  INBOX_START: "/inbox/start",
  INBOX_STOP: (inboxId: string) => `/inbox/${inboxId}/stop`,
  // PUT patches the canned response; DELETE removes the inbox and its captures.
  INBOX_BY_ID: (inboxId: string) => `/inbox/${inboxId}`,
  INBOX_CAPTURES: (inboxId: string, limit: number, offset: number) =>
    `/inbox/${inboxId}/requests?limit=${limit}&offset=${offset}`,
  INBOX_CAPTURES_CLEAR: (inboxId: string) => `/inbox/${inboxId}/requests`,
  INBOX_LIVE: (inboxId: string) => `/inbox/${inboxId}/live`,

  // Collection mock server - a loopback listener answering the collection's
  // saved examples. Verb paths for the same reason the inbox uses them. No PUT
  // and no DELETE: the route table is a start-time snapshot, so changing what a
  // mock serves means starting another one, and stopping is what ends it.
  MOCK_SERVER: "/mock",
  MOCK_SERVER_START: "/mock/start",
  MOCK_SERVER_STOP: (mockId: string) => `/mock/${mockId}/stop`,
  MOCK_SERVER_ROUTES: (mockId: string) => `/mock/${mockId}/routes`,

  // Real-time stats (SSE)
  METRICS_LIVE: (runId: string) => `/runs/${runId}/live`,

  // Time-series metrics (JSON, paginated) - used to hydrate history
  STATS_TIME_SERIES: (runId: string, limit = 5000, offset = 0) =>
    `/runs/${runId}/metrics?limit=${limit}&offset=${offset}`,

  // Server vitals scraped during the run (JSON, paginated, same envelope).
  // Fetched by the history view only when the report says the run recorded some.
  RUN_MONITOR: (runId: string, limit = 5000, offset = 0) =>
    `/runs/${runId}/monitor?limit=${limit}&offset=${offset}`,
};

Note: the old STATS_STREAM SSE constant was removed - live metrics go through METRICS_LIVE only; /stats is now used solely for paginated historical reads.

Request Execution Flow

Single Request Execution

  1. User Action: Clicks "Send" in RequestBuilder
  2. Request Transformation: Frontend format → backend format (raw - no client-side {{variable}} resolution)
  3. Composition: apiService.composeRequest()POST /compose - the engine resolves {{variables}} and inherit auth against the request's collectionId chain and the active environmentId, and returns the execute-ready payload (issue #226)
  4. API Call: apiService.executeRequest() with the composed payload, unchanged → POST /execute, carrying stream: false explicitly
  5. Response Transformation: Backend format → frontend format
  6. Display: Response shown in ResponseViewer

stream is sent on every execute, never elided. It follows the same never-elide rule as httpVersion and the redirect policy, for a sharper reason: the endpoint's two answers are different shapes, so a caller that let an engine-side default decide would not know which one it was about to parse.

Streaming Request Execution (issue #574)

A request whose Event stream setting is on takes the same composition and a different answer. composeForSend is shared with the buffered path - the two must put the identical request on the wire, or a stream would measure something Send does not - and only the last two steps differ:

  1. API Call: apiService.executeStreamRequest()POST /execute with stream: true, answered 202 {runId, eventsUrl, status} at once. There is no exchange yet: the engine has created the run row and handed the transfer to a managed consumer worker.
  2. Tail: useExecutionEvents opens an EventSource on the answer's eventsUrl as given - the engine names where its own events are, and a second spelling of that path in api-endpoints.ts would be a copy that can disagree with the answer. Frames land in execution-events-store; the Events tab renders them live.
  3. Swap to stored truth: on the relay's complete frame the provider fetches GET /runs/:id/report and restore-response.ts maps the trace's events node onto the response, which is the record from then on.

A different EventSource from the SSEClient singleton below. That client belongs to load and scenario runs, is a single connection whose lifetime is the dashboard's, and deliberately never reconnects. This one owns its retry, for the reason the inbox capture stream does (issue #506): EventSource treats any non-200 as fatal, and a reconnect landing inside the engine's stale-claim window meets a 409 run_events_in_use from the claim the previous socket still holds - so a single unlucky disconnect would otherwise end the stream for the life of the tab, silently. Resume travels as ?lastEventId=, which picks up at the frame after the one named, so a dropped consumer re-renders nothing.

An answer missing runId or eventsUrl is a malformed answer and throws, rather than leaving the pane on "streaming" with no run to stop. Refusals - stream with transient, or a stream cap on a payload that declares no stream - come back as a 400 the user has to read, so they are rendered as the response and raised as a toast. A script is no longer one of them: scripts run on a streaming send (issue #575), the pre-request half before the transfer and the post-request half once the stream has terminated.

The payload carries allowScriptRequests here for the same reason the buffered one does (issue #653): the asker is a user at the request editor pressing Send, and the Event stream setting describes the shape of the answer, not what that user's scripts may do. The engine reads the flag before it branches on stream, so a pm.sendRequest behaves identically with the toggle on and off. While only the buffered call sent it, the same button allowed a script-issued request one way and refused it the other.

The renderer sends the inline compose shape ({ request, collectionId, environmentId }) rather than compose-by-id, because Send executes the editor state - possibly unsaved, or a detached History replay copy that has no saved row at all. requestId is attached to the execute payload afterwards purely to link the run to the saved request in History; environmentId scopes both composition and the engine's script context / variable persistence. useVariableResolver() still exists but is preview-only (tab titles, previews, unresolved-token painting) - see variable-resolution.

Auth (bearer/basic/api-key/oauth2) is resolved engine-side from the request's auth object - the app no longer builds Authorization headers itself. When a non-interactive OAuth 2.0 token can't be obtained, the response carries an errorCode of AUTH_REQUIRED (interactive sign-in needed) or AUTH_FAILED, and the request builder surfaces a toast pointing the user at the Auth tab.

Example Request:

await apiService.executeRequest({
  method: "GET",
  url: "https://api.example.com/users",
  headers: { "Authorization": "Bearer {{token}}" },
  elements: [
    {
      id: "el_1",
      kind: "script.pre",
      enabled: true,
      config: { script: "console.log('Pre-request');" },
      origin: { kind: "request", id: "req_123" }
    },
    {
      id: "el_2",
      kind: "script.post",
      enabled: true,
      config: {
        script: "pm.test('Status 200', () => pm.expect(pm.response.code).to.equal(200));"
      },
      origin: { kind: "request", id: "req_123" }
    }
  ],
  followRedirects: true,
  maxRedirects: 10,
  httpVersion: "auto",
  verifySSL: true,
  requestId: "req_123",
  environmentId: "env_456"
});

elements (issue #1512) is the resolved, ordered list of typed behaviours to run - the collection chain's (root→leaf), then the request's own, minus whatever the request's own inherit.disable entries name, each stamped with its origin. A script is one of these now (kind: "script.pre" / "script.post", config.script the text) - it replaces the old preRequestScripts / postRequestScripts ScriptPart[] fields, which the engine now refuses outright (400 naming elements). The renderer builds the list from its editor state (elementsParts() in request-builder/utils/elements-parts.ts, generalizing the old scriptParts()) and it rides through POST /compose untouched - script text is never interpolated; the by-id compose path (used by MCP) resolves the same list engine-side (compose_elements). The engine runs each element at its kind's phase - see docs/engine/elements.md.

Redirect policy, protocol and TLS verification are always sent, never elided. followRedirects, maxRedirects, httpVersion and verifySSL all come from the request's Settings tab and are included on every execute even when they equal the defaults. The engine defaults follow_redirects to true, so omitting a false would follow the 3xx the user asked to inspect - a bug the app shipped with for a long time, when nothing in the renderer sent these fields at all. verifySSL carries the same rule for a sharper reason: the engine verifies unless told otherwise, so an omitted false verifies the certificate the user turned verification off for, and the send fails against the one host the setting exists for. All four go out with startLoadTest(), so a load test exercises the same policy, protocol and trust the request was configured with - there is no separate, load-test-only protocol control; the Settings tab's picker is the only one, and it governs Send and load test alike. httpVersion is "auto" | "http1.1" | "http2": "auto" lets ALPN negotiate, "http1.1" forces HTTP/1.1, and "http2" attempts h2 over TLS with a silent fallback to 1.1 over plain http:// (curl's CURL_HTTP_VERSION_2TLS semantics).

Example Response:

{
  status: 200,
  statusText: "OK",
  headers: { "content-type": "application/json" },
  body: { users: [...] },
  bodyRaw: '{"users":[...]}',
  httpVersion: "HTTP/1.1",
  httpVersionDowngraded: false,
  clientCertificate: "",
  bodyCapped: false,
  timing: { total: 150, dns: 10, connect: 20, ... },
  testResults: [
    { name: "Token was issued", passed: true, source: "pre" },
    { name: "Status 200", passed: true, source: "test" }
  ],
  consoleLogs: [
    { source: "pre", level: "log", message: "Pre-request" },
    { source: "test", level: "warn", message: "slow response" }
  ]
}

testResults entries name the script that asserted them, in execution order: pm.test runs in a pre-request script too, and the Tests pane groups the list by source so an assertion made before the request went out does not read as one about the response (issue #810). An entry restored from a trace written before that carries no source, and is read as the test script's.

consoleLogs entries name the script that wrote them and the console.* level that was called. A bare string is the pre-structured shape an older engine sidecar sends; console/parse-logs.ts decodes both and is the only place that knows the difference (see the engine API reference).

httpVersion here is the negotiated protocol ("HTTP/1.1" / "HTTP/2" / "" when nothing was negotiated) - an outcome, not an echo of the request's own httpVersion. The Raw tab in the response viewer prints it on the request/status line instead of a hardcoded HTTP/1.1.

httpVersionDowngraded says the request asked for http2 and the connection negotiated something older - the one thing neither httpVersion can say alone, since neither knows about the other. The engine computes it, and the renderer carries it as-is rather than comparing the negotiated protocol against the tab's current setting: a restored or replayed response sits beside request state that may have changed since, and the answer belongs to the exchange. ResponseStatusBar draws it as a warning beside the status chip, and only when it is true. Load runs carry the whole-run count as report.summary.httpVersionDowngraded, which LoadTestDetail shows next to the requested protocol - without it a run measured entirely over HTTP/1.1 still read "HTTP/2" there (#215).

clientCertificate names the client-certificate registry entry the exchange presented, as host or host:port, and "" when none matched (issue #707). It is the entry's own spelling, so a wildcard row reads as *.example.com rather than as the host that was dialled - which is the point: it says which row answered (issue #803). The renderer maps that empty string to undefined and ResponseStatusBar draws a chip only when there is one - the same show-it-only-when-it-happened rule the downgrade warning follows. Nothing on the request names a certificate (the engine matches one per transfer by host), which is exactly why the response has to say which was used: two calls to what looks like the same API can differ only in which registry entry matched. The stored trace carries the same value under the same key, so responseFromExecuteResult and responseFromRunResult agree - see client-certificate-funnels.test.ts.

bodyCapped says the engine stopped reading this response at maxDesignResponseBodyBytes (Settings → Limits, default 32MB), so body, bodyRaw and bodySize describe the prefix it read rather than what the server sent (issue #1157). The status and headers are the server's own, so this is a successful response carrying a flag. It is always present on the live body - absent means an engine too old to say, not "not capped" - and the stored trace carries the same key under trace.response.bodyCapped, written only when it happened, so responseFromExecuteResult normalises its false to undefined and the two funnels agree (body-capped-funnels.test.ts).

The renderer keeps it strictly apart from bodyTruncated, which is maxTraceBodyBytes shortening a stored body after the whole of it was read: a re-send recovers from that one, while a capped read is reproduced by a re-send and only a bigger maxDesignResponseBodyBytes changes it. The response pane renders one Callout per fact and both can be on screen at once - "Body truncated for storage" tells the user to re-send, "Body capped while reading" tells them to raise the setting, and collapsing the two into one notice would give the wrong instruction to half the cases.

A third limit is the renderer's alone and reaches no wire field: LARGE_BODY_BYTES (2MB, shared/response-viewer/utils.ts). Above it responseFromExecuteResult stops building the indented body copy - it hands back bodyRaw, which is what would be displayed anyway - and ResponseBody stops formatting, hides the view toggle and shows the first 2MB of the raw body under its own notice. Both are about rendering cost, not about what arrived.

report.sampling carries what each of the run's bounded stores thinned away - successTracesDropped / slowTracesDropped for the trace records behind report.results, and responseSamplesDropped for the buffer post-run test scripts are graded on. That buffer is bounded twice, and one counter reports both: by count (max_response_samples), which displaces an incumbent and so keeps the graded set uniform over the run, and by bytes (max_response_sample_bytes), which stops admitting once the retained bodies fill it. The renderer now reads sampling.responseSampleBudgetSpent to tell which one applied (issue #1192): true means the byte budget ended at least one sample, so the graded set is drawn from the part of the run whose bodies fit rather than uniformly from the whole of it; false means only the count cap displaced anything, so the graded set stays uniform. The key is absent on a summary written before the marker existed, and absent keeps today's uniformity sentence rather than weakening it - only a target whose retained bodies average more than ~256 KiB reaches the budget at the defaults, so absent-as-uniform is the accurate message for nearly every older run. The renderer treats a non-zero count as "this list is a sample of a larger set": SampleRetentionNote (shared) renders under the dashboard's Sampled Requests, the history Samples tab and the Test Validation card, and the sample-count badges say shown rather than captured, since results is capped at 100 by the report route irrespective of retention.

An absent sampling is a run whose stored summary predates the counts, not a run that dropped nothing, so the note stays out rather than asserting a completeness it cannot verify - the same absent-vs-zero rule httpVersionDowngraded follows above.

report.auth follows that rule too. The engine refreshes a header-placed OAuth 2.0 token while a load run is going, and this section is what it did: refreshes[].atSeconds per renewal, plus refreshFailures and a lastError when one was refused. LoadTestDetail renders it as a one-line note in the header (authRefreshNote), a warning when a refresh failed - that failure is what explains 401s appearing partway through an otherwise healthy run. Absent means the run could never refresh (no OAuth 2.0 auth, a non-expiring or query-placed token, autoRefreshToken: false, or an older sidecar); present with an empty refreshes means it was watched and never needed to. The same eligibility rule decides whether OAuth2LoadTestGuard still blocks a run longer than its token - isMidRunRefreshable mirrors the config-and-token cases of the engine's plan_auth_refresh, not its last one, a user-supplied Authorization header that beats the token and that the guard is never handed the headers to see. The two must change together.

Load Test Execution

  1. User Action: Configures and starts load test
  2. Composition: the request half (method/url/headers/body/auth/tests script parts) goes raw through apiService.composeRequest()POST /compose, same as Send - so a load test measures the same composed request Send sends
  3. Request Transformation: composed request half + frontend LoadTestConfig → backend format
  4. API Call: apiService.startLoadTest()POST /runs
  5. Response: { runId: "run_123", status: "running" }
  6. Dashboard Initialization: useDashboardStore().startRun(runId)
  7. SSE Connection: loadTestService.startMonitoring(runId) connects to /runs/:runId/live (a module singleton, so the stream outlives the view)
  8. Metrics Streaming: Real-time metrics update dashboard
  9. Completion: When test completes, fetch final report via GET /runs/:id/report

Example Load Test Request:

await apiService.startLoadTest({
  request: {
    method: "POST",
    url: "https://api.example.com/data",
    headers: { "Content-Type": "application/json" },
    body: { mode: "json", content: '{"key": "value"}' }
  },
  followRedirects: true,
  maxRedirects: 10,
  httpVersion: "auto",
  verifySSL: true,
  mode: "constant_rps",
  duration: "30s",
  targetRps: 100,
  concurrency: 50,
  requestId: "req_123",
  environmentId: "env_456",
  comment: "Stress test",
  // Optional pass/fail budgets. Omitted entirely when none were declared - the
  // engine rejects an empty object rather than starting an unjudged run.
  thresholds: { latencyP99Ms: 50, maxErrorRatePct: 0.1 },
  // Optional data rows, bound one per request sent. Omitted when the dialog's
  // picker holds no file.
  data: [{ id: "1" }, { id: "2" }]
});

Streaming load runs. A load run of a request whose stream setting is on sends stream: true plus maxStreamDurationMs / maxStreamEvents (issue #576). The split is deliberate: whether a request streams is read off the request, because that is a property of the request and lives on its Settings tab, while how much of each stream this run measures comes from the load dialog's two cap fields, which appear only for a streaming request. The caps are always sent for such a run rather than elided as "the engine has defaults" - the engine's default is the user's sseMaxStreamDurationMs setting, which the dialog does not show, so eliding would leave the run bounded by a number the user was never told about while two others were on screen.

Under load a stream is bounded by construction, and reaching a cap is a successful completion, not a timeout - so a streaming run's clean error rate is not evidence the caps went unused. The report answers that with a stream section (RunReport.stream): the per-completion event distribution, the totals, a derived eventsPerSecond, and capped - how many streams a cap ended rather than the server. StreamMetrics (modules/dashboard/components/charts) renders it, and says plainly when every stream was capped, because those counts then measure the caps rather than the target. The section is undefined for a run that did not stream, which is not the same claim as a stream that delivered nothing, so a report without it renders exactly as it did before.

Budgets and the verdict. LoadTestConfig.thresholds (RunThresholds) rides through to POST /runs under the engine's own camelCase metric names, and the report comes back with thresholdValidation - one check per budget plus a verdict of "passed" / "failed" - which ThresholdVerdict (components/shared) renders on the dashboard report and the history Overview. It is the aggregate counterpart to testValidation: a pm.test script sees one response at a time and cannot assert a p99 or an error rate, so a run whose every assertion passed can still have missed its budget. undefined is a run that declared none - not a run that passed nothing - so a report without the section renders exactly as it did before budgets existed. The dialog seeds its p99 field from the client sloThresholdMs setting, which until then only annotated a chart.

maxAssertionFailureRatePct (issue #1497) is the sixth budget, judged against the run's combined assert.* element and pm.test tally rather than against testValidation's per-response view. thresholds.failRun: true (LoadTestConfigDialog's own Switch, beside the budget fields rather than in BUDGET_FIELDS - it is a flag over the budgets, not one itself) changes what a missed budget does: the run's terminal status becomes "failed" instead of "completed", so the history list and the taskbar failure cue pick it up, not only the verdict section.

A collection (design-mode) run is judged too (issue #1564): RunCollectionDialog's own Pass/fail budgets disclosure sends the identical thresholds block, top-level on the payload beside failOnSchemaError and elements, reusing budgets.ts's field table and payload builder rather than a second copy. It is not gated by the dialog's Load test switch - the engine evaluates thresholds for a design-mode run and a load run of the same folder alike - so the same thresholdValidation section and ThresholdVerdict rendering apply whichever executor ran the sequence. See POST /runs's thresholds section for how a collection run's error rate, latency percentiles and throughput are derived from the steps it actually sent.

custom.<name>.<stat> budgets (issue #1579) are the seventh thing the block can carry, and the only dynamic one: a ceiling on a value the run records itself through a metric.record element or a pm.metrics call, keyed by the name it was recorded under and one of p50, p95, p99, max, value, rate. RunThresholds holds them as a pattern index signature ([key: `custom.${string}`]: number | undefined), so the six fixed fields keep their own types and failRun keeps its boolean. Both dialogs declare them as free-text rows (CustomBudgetRows.tsx) rather than as a picker of recorded names: the engine checks the key's shape and the value's sign and does not cross-check the name against the run's own metric.record elements - an unmatched name is reported evaluated: false rather than refused - which is the call MCP's thresholdsInput already made for this family (catchall). Like the six fixed keys, these are not gated by RunCollectionDialog's Load test switch either - execute_scenario_run evaluates a design-mode run's custom metrics against the same declared budgets a load run's are judged against.

The engine range-checks this payload before it creates the run row and answers a violation with 400 invalid_run_config (accepted ranges are tabulated under POST /runs). The renderer's own limits live in LOAD_TEST_LIMITS (src/constants/load-test.ts) and must stay at or inside the engine's.

success_sample_rate is a period, not a percentage

The engine keeps a success trace when counter % success_sample_rate == 0 - one in every N. The dialog's control is a percentage, and the renderer converts between them with successSamplePeriod (constants/load-test.ts): 100% becomes a period of 1, 1% becomes 100, and the default 10% is the fixed point where the two units coincide. Sending the percentage straight through, as the renderer did before, inverts the slider - "100% - everything" kept 1%.

A 0 is a division by zero engine-side, so the control's floor is 1%. "Keep no success traces" is the Save timing breakdown toggle, which gates storage outright.

Dialog ceilings are a user setting

LOAD_TEST_LIMITS (constants/load-test.ts) holds the ranges the load dialog offers, and four of its ceilings are user-adjustable in Settings → Load testing (loadTestCeilings on client-settings-store). The dialog reads them through resolveLoadTestLimits, never off the constant.

These are the app's policy and sit inside the engine's own bounds, which are crash guards rather than throttles: a run's concurrency becomes an eager per-worker curl-handle pre-allocation, so the engine caps it at 10x event_loop::MAX_CONCURRENT. LOAD_TEST_CEILING_BOUNDS pins each settable ceiling at that guard, so no value on the settings screen can compose a run the engine rejects. The floors are not settable at all - below them the values are not "small", they are unusable (a concurrency of 0, a sample period of 0).

Error Handling

HTTP Errors

All HTTP errors are transformed to ApiError:

try {
  await apiService.executeRequest(...);
} catch (error) {
  if (error instanceof ApiError) {
    console.error(error.userFriendlyMessage);
    console.error(error.statusCode);
    console.error(error.errorCode);
  }
}

Network Errors

Network errors (timeout, connection failed) are caught and displayed:

if (error.isTimeout) {
  // Show timeout message
} else if (error.isNetworkError) {
  // Show network error message
}

SSE Errors

The SSE client does not reconnect. EventSource cannot set Last-Event-ID on a fresh connection, so a manual reconnect would re-request the topic from offset 0 and duplicate every tick already plotted.

  • Transient errors (CONNECTING): left to the browser's own retry, which does carry Last-Event-ID
  • Terminal errors (CLOSED): connection disposed and the close handler runs, which converges on GET /runs/:id/report

Connection Management

Health Checking

The app polls /health endpoint to verify engine connectivity, at one of two speeds depending on whether the last poll succeeded:

useHealthQuery() // TIMING.HEALTH_RECONNECT_POLL_INTERVAL_MS (1s) while erroring,
                 // TIMING.HEALTH_CHECK_INTERVAL_MS (30s) once connected

The window loads alongside the engine rather than after it, so an ordinary launch spends its first seconds starting rather than connected; polling that state at the 30s cadence could leave a launch showing it for half a minute after the engine was already serving. A poll that succeeds right after one that failed also triggers queryClient.invalidateQueries() once - collections, runs and config gave up after two retries while the engine was down, a connection refused by a closed port is a plain Error rather than an ApiError, and refetchOnReconnect only fires on the browser's online/offline event, which localhost never changes - so nothing else would ever revisit their error state once the engine came back.

A failed poll does not mean engineStatus becomes unreachable outright: engineStatusAfterFailedPoll (queries/health.ts) reads starting while an engine is known to be coming up and is still inside TIMING.ENGINE_STARTUP_GRACE_MS (45s) of the moment it began, and unreachable otherwise - past that window, or after an engine that had answered stops answering with nothing starting. The moment it began is engineStartWindow on engine-store, opened by this hook on mount - which is when the main process spawns the engine and starts spending its own budget on the same wait - and again by useEngineRestart before it invokes the restart IPC, because a restart kills the daemon and spawns a fresh one that repeats the whole cold start with the port down for all of it (#1227). Measuring from the hook's mount alone called every such restart a failure, since an engine had answered: the one this one replaced. The restart opens the window and never writes engineStatus, so this hook remains the only thing that classifies; a restart the main process reports as failed closes the window again, and the next failed poll goes back to owing the user its reason.

That 45s is the same budget the main process spends on a cold engine before it gives up and logs EngineNotReadyError (ENGINE_HEALTH_POLL_BUDGET_MS in electron/constants.ts) - it is the same question asked from the other side of the process boundary - and since the two files' tsconfigs share no module graph, health.test.ts reads the constant out of electron/constants.ts's source text and asserts it equals TIMING.ENGINE_STARTUP_GRACE_MS, so the two cannot drift apart unnoticed.

Health Response:

{
  status: "ok",
  version: "0.3.0",
  workers: 8,
  // Optional (issue #922): present only when this engine startup restored the
  // database from its backup or started fresh because it could not. Absent on a
  // clean start, which is also what a genuine first run gives.
  recovery?: {
    outcome: "restored_from_backup" | "started_fresh_quarantined"
           | "backup_also_corrupt" | "deleted_corrupt",
    at: 1755870000000,   // epoch ms
    databasePath: "/home/someone/.local/share/vayu/vayu.db",
    // Optional (issue #984): where the unopenable database was moved to, when
    // it was moved rather than deleted. Absent for `deleted_corrupt`.
    quarantinedPath?: "/home/someone/.local/share/vayu/vayu.db.corrupt-1755870000000"
  }
}

useHealthQuery mirrors recovery onto engine-store, and RecoveryBanner is its only reader - it is the one thing that tells the user their data was restored or wiped, and the only place the quarantined file and its sqlite3 ... .recover command are named. Its copy is a Record over the outcome union, so an outcome the engine adds without copy for it fails pnpm type-check rather than being announced as whichever branch came last.

Engine Startup

The Electron main process (electron/sidecar.ts) manages engine lifecycle: - Spawns engine process on app start, alongside creating the window rather than before it - first paint does not wait on the engine - Monitors health via /health endpoint, on a ramped poll that leaves a live-but-slow engine to the renderer's own health poll instead of quitting the app - Handles graceful shutdown on app quit

Best Practices

  1. Always use apiService: Don't call httpClient directly
  2. Handle errors: Always wrap API calls in try/catch
  3. Use user-friendly messages: Display error.userFriendlyMessage to users
  4. Transform data: Use service layer for format transformation
  5. Cache queries: Use TanStack Query for automatic caching
  6. Disconnect SSE: Always disconnect SSE when component unmounts

Testing

Mocking API Calls

Use TanStack Query's query client for testing:

import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } }
});

Mocking Services

Mock apiService methods:

jest.spyOn(apiService, 'executeRequest').mockResolvedValue(mockResponse);

Troubleshooting

Engine Not Responding

  1. Check if engine is running: curl http://127.0.0.1:9876/health
  2. Check engine logs in Electron console
  3. Verify port 9876 is not blocked

SSE Not Connecting

  1. Verify load test is running (status: "running")
  2. Check browser console for SSE errors
  3. Verify endpoint: /runs/:runId/live or /runs/:runId/metrics

CORS Errors

Should not occur (same-origin: localhost), but if they do: - Verify engine CORS settings - Check if request is going to correct origin