http api
Every endpoint the server serves. This is generated from kayak_core::api_docs::endpoints() — the table api_router is folded over — so it describes the routes that exist and cannot describe any that don't. The same table is served as an OpenAPI 3.1 document, which a running server also renders at /api/reference with a panel you can fire calls from.
Access is the badge on each endpoint, and it is enforced by the middleware the router applies from this same entry rather than being a second fact that agrees with it today. On a server with no accounts configured, none of it applies: nobody is identified, so nothing is checked. See authentication.
Bodies link to the schemas they name, which are generated from the Rust types the handlers actually deserialize into.
pipelines
The running graph. Creating and deleting pipelines takes effect immediately and writes nothing to disk.
GET /api/pipelines
Every pipeline the server is running
read listPipelines — Any signed-in user.The running graph, as the configs the pipelines were built from — each with the id it is actually running under, which is generated when the config omitted one.
This is the runtime's view rather than the file's: a pipeline created since startup is here and not in the config file, and GET /api/settings is what says whether the two have diverged.
responses
| status | body | description |
|---|---|---|
200 | [PipelineDto] | The running pipelines, in no particular order. |
500 | ApiError | Something went wrong on the server. The body says what. |
POST /api/pipelines
Build and start a pipeline
admin createPipeline — Signed-in users with theadmin role.The body is one pipeline's config, exactly as it would appear in the pipelines array of a config file. Omitting id generates a readable random one, which comes back in the response.
The pipeline is built and started before the response is sent, so a 201 means it is running — a component that could not be built (an unknown connection, an unresolved secret) is a 422 and nothing is started. Nothing is written to disk: the config file is a load source and a save target, never a mirror of the runtime.
request body — Config The pipeline to build.
responses
| status | body | description |
|---|---|---|
201 | PipelineDto | Built and running, with the id it took. |
409 | ApiError | A pipeline with this id is already running. |
422 | ApiError | The config is well-formed JSON but could not be built — an unknown connection, a missing secret, an upstream that does not exist. |
500 | ApiError | Something went wrong on the server. The body says what. |
DELETE /api/pipelines/{pipeline_id}
Stop and remove a pipeline
admin deletePipeline — Signed-in users with theadmin role.Cancels the pipeline's run loop and drops it from the graph. Pipelines downstream of it keep running and stop receiving from it.
Like creating one, this writes nothing to disk.
path parameters
| name | description |
|---|---|
pipeline_id | The id the pipeline is running under. |
responses
| status | body | description |
|---|---|---|
204 | — | Stopped and removed. |
404 | ApiError | No pipeline is running under that id. |
500 | ApiError | Something went wrong on the server. The body says what. |
POST /api/pipelines/{pipeline_id}/messages
Post messages into a pipeline
public ingestMessages — Callable without credentials, even when authentication is on.The endpoint a pipeline's http input serves. Every pipeline with one has this path, derived from its id, and it exists for as long as the pipeline is running — this is how a system pushes data into kayak without a broker in between.
The body is one JSON message or an array of them; an array arrives as a single batch, so posting ten messages is one pass through the transforms rather than ten. There is no envelope and no schema: whatever is posted is what the transforms see.
Accepted means queued, not processed. The batch is handed to the pipeline's run loop and the response is sent without waiting for the outputs, so a 202 says nothing about whether the data has landed anywhere.
This endpoint does not use the server's sign-in — it is a data plane, and a system pushing readings should not need an account that can rewrite the graph. Protecting it is the http input's own auth field: a token the sender repeats in a header, declared per pipeline. Without one the endpoint takes anything that reaches it, which is the default.
path parameters
| name | description |
|---|---|
pipeline_id | The id of the pipeline to post to. |
request body — IngestRequest One message, or an array of messages to deliver as one batch.
responses
| status | body | description |
|---|---|---|
202 | IngestResponse | Queued for the pipeline, with the number of messages taken. |
404 | ApiError | No pipeline is running under that id, or the one that is has no http input to post to. |
401 | ApiError | The input has an auth and this post didn't satisfy it. This is the input's own credential, not the server's sign-in: an account on the server does not let you post, and the token does not let you do anything else. |
503 | ApiError | The pipeline's queue is full — it is not reading as fast as this is being posted. Nothing was taken; send it again. |
500 | ApiError | Something went wrong on the server. The body says what. |
GET /api/pipelines/{pipeline_id}/history
What a pipeline has been doing, after the fact
read getPipelineHistory — Any signed-in user.Throughput and failures over time, kept in the server's memory so that something which broke overnight can still be read in the morning.
This is the counterpart to /events, not a replay of it. The event stream is a live sample: it is only produced while a browser is attached and it drops passes under load on purpose. History is fed by counters the run loop keeps regardless of who is watching, so it is complete in what it counts — and correspondingly it carries no message payloads at all, only counts, and failures aggregated to one entry per distinct message with a first-seen, a last-seen and a tally.
Buckets are contiguous and oldest first, including empty ones: a run of zeroes is a pipeline that stopped, which is a different fact from a gap and is spelled differently.
An unknown or newly created pipeline answers with an empty history rather than a 404 — a pipeline that has not done anything yet is not an error. How much is kept is the history.retention_secs in the server config; when that is zero nothing is recorded and this always answers empty.
path parameters
| name | description |
|---|---|
pipeline_id | Id of the pipeline. |
query parameters — all optional, so a bare request to this path is a working request.
| name | description |
|---|---|
resolution | coarse (the default) — a minute a bucket, over the configured retention, which is the overnight record. fine — five seconds a bucket over the last half hour, which is what a card's live chart is backfilled from so it starts full rather than drawing itself over the next two minutes. |
responses
| status | body | description |
|---|---|---|
200 | PipelineHistory | The pipeline's history at the resolution asked for. |
POST /api/scripts/dry-run
Run a script over some messages, without creating a pipeline
admin dryRunScript — Signed-in users with theadmin role.A script transform is the one component whose configuration can be wrong in a way the config's shape cannot express: for every other component, a config that deserializes and builds does what it says, and for this one the interesting mistakes are all inside a string. This endpoint is where that string gets checked.
It compiles the script and runs it over the messages in the body, through the same runner and under the same operation budget and sandbox a running transform gets — a dry run that could disagree with production would be worse than none, because it would be trusted.
A script with a bug in it is a 200, not a 400. The request was well formed and the server answered it completely; where the bug is is the answer. The response is a tagged union: emitted carries the batches, failed carries the message with a line and column an editor can point at. A 400 here means the request itself was wrong — malformed JSON, or a file source naming something unreadable.
State is never live. The run gets a private bucket seeded from state in the body and thrown away afterwards, and what it holds at the end comes back in the response. Reading production state would make the answer depend on what the server happened to be doing; writing it would give a dry run side effects.
request body — DryRunRequest The script, and the messages to run it over.
responses
| status | body | description |
|---|---|---|
200 | DryRunResponse | The script ran, or it did not compile — the outcome field says which. |
400 | ApiError | The request itself was wrong: malformed JSON, or a file source that could not be read. |
500 | ApiError | Something went wrong on the server. The body says what. |
POST /api/inputs/sample
Fetch a few real messages from an input, without creating a pipeline
admin sampleInput — Signed-in users with theadmin role.Configuring a stream you cannot see is guesswork, and every field reference downstream — a column's field, a filter's comparison — is a name someone had to already know. This builds the input in the body exactly as a pipeline would, takes up to max_messages from it within timeout_ms, and drops it.
The real input, including its envelope, so the metadata fields the messages will actually carry are in the sample too. Its buffer is the one thing ignored: a buffer's job is to make the pipeline wait, which is not what a sample is for. Anything the sample did differently comes back in notes.
Sampling is not free for every kind of input, and the ones where it isn't say so. A kafka sample runs under a throwaway consumer group, so it neither rebalances the pipeline's group nor commits on its behalf; an mqtt sample connects under a client id of its own, because a broker disconnects the older client holding one. An http input is refused outright with a 400 — it is posted to rather than read from, so there is nothing to fetch.
No messages is a 200 with an empty list. A subject nobody is publishing to is a real state of the world and the answer to the question asked; none of these inputs can replay what was published before the sample started.
request body — SampleRequest The input to read from, and how much to take.
responses
| status | body | description |
|---|---|---|
200 | SampleResponse | The sample was taken — outcome says whether it produced messages or failed on the way. |
400 | ApiError | The request itself was wrong: malformed JSON, an input that isn't a kind of input, or one that cannot be sampled at all. |
500 | ApiError | Something went wrong on the server. The body says what. |
POST /api/pipelines/dry-run
Run a draft's transforms over some messages, without creating a pipeline
admin dryRunPipeline — Signed-in users with theadmin role.What a map writes, what a filter drops, what a reduce collapses a batch to — questions the config cannot answer and one real message can. This builds the transforms in the body exactly as a pipeline would and puts the messages down the chain, reporting what each stage handed on.
Per stage and as a list of batches, because that is where the answer usually is: a splitter hands on several batches, a filter that dropped everything hands on none, and a buffer hands on nothing because it is still holding what it was given. What a transform only releases when the chain is drained is reported separately, as on_flush, so a buffer doesn't look like it passes everything straight through. A transform that is still holding what it was given hands on nothing at all, and the chain says so rather than pretending the messages came through: a dry run has no tick to give a window that has thirty seconds left on it.
There are no outputs and there cannot be. A dry run that emitted would be a pipeline; everything up to the outputs is a question about the data, and the outputs are the part that changes somebody else's system.
State is never live, exactly as for a script dry run: the buckets are private to the request, seeded from buckets in the body, returned in the response and thrown away with it.
A transform that cannot be built, or that fails on a message, is a 200 whose outcome is failed — the request was carried out and where it broke is the answer. The stages that completed first come back with it.
request body — PipelineDryRunRequest The messages, and the transforms to put them through.
responses
| status | body | description |
|---|---|---|
200 | PipelineDryRunResponse | The chain ran, or it broke on the way — the outcome field says which. |
400 | ApiError | The request itself was wrong: malformed JSON, or a transform that isn't a kind of transform. |
500 | ApiError | Something went wrong on the server. The body says what. |
connections
The systems pipelines talk to, named once and referred to by the components that use them.
GET /api/connections
The connections pipelines can name
read listConnections — Any signed-in user.Keyed by name, which is the same shape as the connections file itself — what the UI lists and what gets committed are one thing, so there is no second format to keep in step.
Credentials come back as the unresolved ${NAME} templates they are configured as, never as their values.
responses
| status | body | description |
|---|---|---|
200 | Connections | Every configured connection, in name order. |
POST /api/connections
Add a connection
admin createConnection — Signed-in users with theadmin role.Changes what the next pipeline build can name, and nothing else: a component reads its connection once, when it is built, so editing or adding one reaches only new and rebuilt pipelines.
Like creating a pipeline this writes nothing to disk; the save does, and it writes the config and the connections file together.
request body — CreateConnectionRequest The connection, and the name to file it under.
responses
| status | body | description |
|---|---|---|
201 | CreateConnectionRequest | Added, echoed back as stored. |
409 | ApiError | A connection of that name already exists. |
500 | ApiError | Something went wrong on the server. The body says what. |
DELETE /api/connections/{connection_id}
Remove a connection
admin deleteConnection — Signed-in users with theadmin role.Refused while a running pipeline still names it — that comes back as a 409 listing the pipelines, so the answer says what to do about it.
path parameters
| name | description |
|---|---|
connection_id | The name the connection is filed under. |
responses
| status | body | description |
|---|---|---|
204 | — | Removed. |
404 | ApiError | No connection of that name exists. |
409 | ApiError | Running pipelines still name it; the body lists them. |
500 | ApiError | Something went wrong on the server. The body says what. |
state
What the pipelines remember between batches. Read-only: buckets are declared in the config and filled by remember transforms, so there is nothing here to write.
GET /api/state
The state buckets and how full they are
read listStateBuckets — Any signed-in user.One entry per bucket declared under state in the config, in name order, with the number of keys it is currently holding and the bounds it is held to.
Buckets are not created or deleted through the API — they are part of the graph's logic and live in the config file, so this family is read-only.
responses
| status | body | description |
|---|---|---|
200 | BucketSummary | Every declared bucket, in name order. |
GET /api/state/{bucket}
What one bucket is holding
read getStateBucket — Any signed-in user.The keys and the values remembered under each, most recently written first — which is the order that makes a live bucket readable, since the key that just changed is the one worth seeing.
Capped: a bucket may hold thousands of keys and this returns a page of them, with truncated saying so and keys giving the real total. It is a snapshot taken under the bucket's lock, so it is consistent with itself and stale the moment it is sent.
path parameters
| name | description |
|---|---|
bucket | Name of the bucket, as declared in the config. |
responses
| status | body | description |
|---|---|---|
200 | BucketContents | The bucket's contents. |
404 | ApiError | No bucket of that name is declared. |
config
The config file: how the server was started, writing the running graph out to it, and throwing the graph away to start again from it.
GET /api/settings
How the server was started, and whether it has drifted
read getSettings — Any signed-in user.Which config file the server is working against, where a save would land, and whether the running graph has diverged from what was last loaded or saved.
The absence of a config file doesn't mean edits can't be saved: it means there is no file yet, and a save creates one.
responses
| status | body | description |
|---|---|---|
200 | SettingsDto | The server's configuration state. |
POST /api/config/save
Write the running graph to a config file
admin saveConfig — Signed-in users with theadmin role.Writes the running pipelines out in a deterministic order (topological, ties by id) via a temp file and a rename, because the result is meant to be committed. The connections file and the canvas layout are written beside it by the same save — a config saved without the connections it names would not start.
name is a bare file name, not a path, and is validated as one: the file lands in the server's save directory and nowhere else. Using the loaded file's own name is how you overwrite it.
format picks JSON or YAML; leaving it out takes the format from the name's extension. On a server started without --config this is how a config file comes into existence at all, and from that save on it is the file revert reloads.
overwrite defaults to true, which is what makes saving over the loaded file the ordinary thing it has always been. Sending false turns the save into a create: if the name — or either of the two files written beside it — is already on disk, the request is refused with a 409 and nothing is written. That is what the UI's project creator sends, since it suggests a file name into a directory its user has often never looked at.
request body — SaveConfigRequest The file name to write, optionally the format, and whether an existing file may be replaced.
responses
| status | body | description |
|---|---|---|
200 | SaveConfigResponse | Written, with the path it landed at. |
409 | ApiError | overwrite was false and the file — or one of the two written beside it — is already there. Nothing was written; the message names the files. |
422 | ApiError | name is not a bare file name. |
500 | ApiError | Something went wrong on the server. The body says what. |
POST /api/config/revert
Throw the running graph away and reload the config file
admin revertConfig — Signed-in users with theadmin role.The undo for a session of editing, and as destructive as it sounds: every running pipeline is stopped and the graph is rebuilt from the file.
The file is parsed before the runtime is torn down, so a file broken by hand costs you nothing. The connections are reloaded first, since the pipelines being rebuilt name them. It waits for the old pipelines to actually stop before rebuilding, so the response landing means the new graph is the only one running.
responses
| status | body | description |
|---|---|---|
204 | — | Reloaded; the graph is what the file says. |
500 | ApiError | There is no config file to revert to, or it could not be read or parsed — in which case the running graph is left alone. |
layout
Where the cards sit on the canvas. Not configuration — this is written to its own file, and immediately.
GET /api/layout
Where the cards sit on the canvas
read getLayout — Any signed-in user.Served separately from /api/pipelines because it is a different kind of thing: that is what the server is running, this is how someone chose to look at it. A client that ignores this endpoint gets an automatically laid out graph, which is the point.
Only pipelines someone has actually moved appear.
responses
| status | body | description |
|---|---|---|
200 | LayoutFile | The stored arrangement. |
PUT /api/layout
Replace the arrangement and write it to disk
admin replaceLayout — Signed-in users with theadmin role.The whole map, not a patch: the canvas already holds the complete arrangement, and a full replacement is what makes "reset everything to automatic" a send of {} rather than its own endpoint.
This is the one edit that writes immediately rather than waiting for a save, and it never counts as an unsaved change — moving a card changes nothing the server runs, so there is nothing worth reviewing before it lands. Without a config file there is nowhere to write, and the arrangement is kept in memory until a save creates one.
request body — LayoutFile The complete arrangement.
responses
| status | body | description |
|---|---|---|
204 | — | Stored, and written if there is a file to write. |
500 | ApiError | Something went wrong on the server. The body says what. |
events
What the pipelines are doing, as it happens.
GET /events
What the pipelines are doing, as it happens
read streamEvents — Any signed-in user.A text/event-stream of UiEvents: a batch arriving at a stage, or a failure handling one. Each SSE data: field is one event as JSON.
It is a broadcast that drops rather than blocks, so a slow consumer misses events instead of slowing the pipelines down — which is what seq is for, since a gap in it is the honest report of what was missed. Run loops only publish at all while somebody is listening.
The stream is explicitly a dev-tooling affordance rather than a durable feed, and is marked temporary in the source.
responses
| status | body | description |
|---|---|---|
200 | UiEvent | An event stream that stays open. |
auth
Signing in and out. Present on every server; on one with no accounts configured they report that there is nothing to sign into.
GET /api/auth/me
Who the caller is, and whether this server asks
public whoAmI — Callable without credentials, even when authentication is on.authentication_required says whether this server checks credentials at all — a server started without a --server-config, or with one declaring auth: {type: none}, answers false and lets everybody do everything.
username and role describe the caller, and are both null for one who presented nothing. Note that a null role is not the same as read: a reader may see the graph, a signed-out caller may not.
Callable without credentials, necessarily — it is the endpoint that answers 'do I need to show a login page'.
responses
| status | body | description |
|---|---|---|
200 | AuthDto | Who you are. Not an error even when the answer is nobody. |
POST /api/auth/login
Exchange credentials for a session
public login — Callable without credentials, even when authentication is on.Checks a username and password against the accounts in the server's settings file and, on success, sets an HttpOnly session cookie.
This is for browsers. Everything else should send Authorization: Basic on each request instead and never come here — the cookie exists because EventSource, which the UI consumes /events with, cannot send headers.
A wrong password and an unknown username are the same 401, deliberately: the endpoint is not a way to find out who has an account. On a server with no accounts configured this is not an error either — it answers 200 with authentication_required false, because there is nothing to sign into.
request body — LoginRequest The credentials to check.
responses
| status | body | description |
|---|---|---|
200 | AuthDto | Signed in. The session cookie is in Set-Cookie. |
401 | ApiError | Wrong username or password — the body does not say which. |
500 | ApiError | Something went wrong on the server. The body says what. |
POST /api/auth/token
Exchange an identity provider's JWT for a session
public tokenLogin — Callable without credentials, even when authentication is on.The embedding flow's endpoint, on a server whose auth section is jwt: a host application that already holds a token from the shared identity provider — Cognito, Keycloak — puts it on the iframe URL as ?auth_token=, and the UI posts it here once. The token is checked against the issuer's published keys and, on success, exchanged for the same HttpOnly session cookie a password login sets — so the token itself appears in exactly one request and never in an access log again.
The session ends no later than the token's exp: the cookie must not outlive the identity provider's word that the caller is signed in.
API callers don't need this exchange — on a jwt server, Authorization: Bearer <token> works directly on every endpoint.
Every way of being refused is the same 401, deliberately: an expired token, a wrong issuer and a server that doesn't take tokens at all are not distinctions worth handing to a guesser.
request body — TokenLoginRequest The token to check.
responses
| status | body | description |
|---|---|---|
200 | AuthDto | Signed in. The session cookie is in Set-Cookie. |
401 | ApiError | The token was not accepted, or this server does not take tokens. |
500 | ApiError | Something went wrong on the server. The body says what. |
POST /api/auth/logout
End the session this request carries
read logout — Any signed-in user.Clears the cookie in the browser and drops the session on the server, so a copy of the cookie taken from somewhere else stops working too.
Idempotent: 204 whether or not there was a session to end.
responses
| status | body | description |
|---|---|---|
204 | — | Signed out. |
reference
The API describing itself.
GET /api/docs
The component reference, as data
public listComponents — Callable without credentials, even when authentication is on.Every input, transform, output and connection kayak can build, with their fields, types and documentation — reflected out of the config schemas, so it cannot drift from what the server actually accepts.
The /docs page generates the same thing in the browser from the same code, so this endpoint isn't what renders it. It exists because the component reference is useful to things that aren't a browser: a config linter, editor completion, a test.
responses
| status | body | description |
|---|---|---|
200 | [ComponentDoc] | Every component, grouped by nothing — family says which plugin point each one plugs into. |
GET /api/openapi.json
This API, as an OpenAPI 3.1 document
public getOpenApi — Callable without credentials, even when authentication is on.Generated from the same table the routes are registered from, with schemas reflected out of the Rust types — so it describes the server that is serving it.
Point a renderer, a client generator or a contract test at it. GET /api/reference is one such renderer, served alongside.
responses
| status | body | description |
|---|---|---|
200 | OpenApiDocument | The OpenAPI document. |
GET /api/reference
The rendered API reference
public apiReference — Callable without credentials, even when authentication is on.An HTML page rendering /api/openapi.json, with a request panel for trying endpoints against this server.
The /docs page in the UI covers the same endpoints in kayak's own furniture; this is the full reference, schemas and all.
responses
| status | body | description |
|---|---|---|
200 | html | The reference page. |