Using the Backend API
As an instance administrator, you may want to connect a script or a custom interface to Pointy. A sample-entry form, for example, could create the steps for an experiment while Pointy stores them and runs the analysis. This guide explains how to read a consistent version of the project, save changes, and follow a run's progress.
Use the generated API Reference for route names and request schemas. This guide adds the behavior around those requests and covers routes missing from that reference. You do not need the API to work on an analysis: use the AI Agent or step controls.
Connecting to an instance
The supplied nginx host serves the API under /backend/. The backend itself listens on port 8081 with no prefix. Thus /backend/projects through nginx and /projects on the backend refer to the same route. The host module's /api/ proxy goes to port 3000; it is not the backend API prefix.
Deploy a browser client on the same origin, or give it a same-origin reverse proxy. The backend has no login/session-token system or per-user authorization. The supplied host can use nginx basic authentication; handle that at your deployment boundary and use HTTPS. A commit hash or share URL does not grant access on its own.
Cross-origin clients
CORS is route-specific, not a general permission to build a cross-origin client:
/step-status-streampermits onlyhttp://localhost:3000, with credentials./agent/*,/cluster-status-stream, and many ordinary resource routes allow any origin, without a credentialed cross-origin policy.- Review routes,
/project-status,/notices, the project batch routes, and/src-files/seekhave no explicit CORS policy in the current backend.
The ordinary policies allow Content-Type; stream policies also allow Last-Event-ID. They do not provide a general cross-origin Authorization header policy. CORS does not protect the backend from non-browser callers. See the admin guide before exposing an instance.
Read one consistent revision
Start with GET /commit-hash, which returns plain text. Pass that value as commit on reads that support it, such as /projects, /step-config, /presets, file routes, notices, and autocomplete. Omitting it reads the current repository state for that request; several unpinned requests need not see the same revision.
For example, from a terminal, with BASE set to your instance:
BASE=https://pointy.example.org/backend
COMMIT=$(curl --fail --silent --show-error "$BASE/commit-hash")
curl --fail --get "$BASE/projects" --data-urlencode "commit=$COMMIT"
curl --fail --get "$BASE/step-config" --data-urlencode "commit=$COMMIT"Supply your deployment's credentials separately if required. Use full commit hashes for reproducible links, not a moving name such as HEAD.
GET /projects returns an object keyed by project id. Each project contains its metadata and steps, whose entries contain an evaluated def plus that project's hidden and sortKey values. A step can appear in several projects without becoming a different step. lastModifiedAt, when available, comes from Git history. validationErrors explains references to missing presets, templates, or steps.
GET /step-config returns { "version": 4, "templates": { ... } }. Consume the fields' widgets and shapes rather than hard-coding a form per template. The Type Reference describes that document. GET /presets returns named template bundles. These responses depend on the user repository, so their OpenAPI schemas are deliberately less specific than the JSON you will receive.
Creating and editing workflow records
Project and step ids are integers. Most routes take them as query parameters (id, project_id, or entity_id), not path segments. A create request allocates the id and returns the evaluated record.
For a repository containing the setup guide's hello template, a project body is:
{
"name": "Example analysis",
"hidden": false,
"sortKey": null,
"templates": ["hello"],
"steps": []
}Send it to POST /projects. Use exactly one of templates or preset. Then send a step body to POST /step?project_id=<returned-id>:
{
"type": "hello",
"name": "First greeting",
"note": "A small build to check the instance.",
"args": { "message": "Hello, research!" }
}These requests write and push repository commits. Try client development against a disposable project or instance, not a production analysis.
Important distinctions:
PATCH /step?id=…andPATCH /projects?id=…replace the saved record from the submitted JSON. They are not JSON Merge Patch. Send all authored fields you intend to keep. Do not send an evaluated project back unchanged: its expandedsteps[].defrecords must become references{ "id": 42, "hidden": false, "sortKey": null }. Omit computed fields such aslastModifiedAtandvalidationErrors.POST /step?source_id=…copies the source step's repository-backed files. The body still supplies the new step's definition;source_idis not a request to infer it all for you.POST /project-entities?project_id=…&entity_id=…assigns an existing step.DELETEremoves that assignment, not the step definition. The/batchvariant accepts an array of integer step ids.POST /projects/batchaccepts[{ "id": 1, "record": { ... } }]and commits the project updates together. Ordinary separate requests do not form a transaction.- Edits target the current repository branch. Appending
committo a write route that does not declare it does not make the write historical or conditional. There is no generalIf-Matchor compare-and-swap edit contract.
Inputs from other steps are JSON objects such as { "step": 42 }; several inputs are an array of those objects. Download steps take args.url, while the backend supplies the recorded file hash and download time. Uploads use a separate multipart request. Do not treat generated download details or review annotations as ordinary editable form fields.
Running work
POST /run-step?id=42&commit=<hash> starts asynchronous evaluation and scheduling. A successful empty response means the request was dispatched, not that scheduling or execution succeeded. The backend builds missing upstream dependencies too, reusing available outputs and in-flight jobs.
Listen for status snapshots, and use GET /step-log?id=42&commit=<hash> for a build log. A log may identify a failed prerequisite rather than the step's own derivation. A missing log returns 404; it does not establish that the step succeeded.
POST /stop-step?id=42&commit=<hash> targets the build for that revision. Use the same commit you used to run or inspect it. A client displaying a reviewed step must explicitly use its reviewed revision to read or rebuild the approved output; ordinary file/build endpoints do not automatically substitute that revision for you.
Live status streams
Open one GET /step-status-stream connection for the application. It has no project or commit parameters. Named SSE snapshot events carry JSON:
{
"projectId": 1,
"commit": "a-full-git-commit-hash",
"steps": [
{ "stepId": 42, "status": "success" },
{ "stepId": 43, "status": "failure", "error": "Build failure detail" }
]
}Status strings are not-started, running, success, and failure. Loading is a client-side state. Match both projectId and commit before replacing the statuses in a pinned view; this stream includes other projects and revisions too.
After subscribing, call POST /project-status?project_id=1&commit=<hash> to request a fresh snapshot. That empty response also precedes the asynchronous result. On connection or reconnection the bus replays up to 256 recent snapshots in order. This is bounded in-memory history, not an event log or a guaranteed complete initial snapshot.
A minimal browser listener is:
const statusStream = new EventSource("/backend/step-status-stream");
statusStream.addEventListener("snapshot", event => {
const snapshot = JSON.parse(event.data);
console.log(snapshot.projectId, snapshot.commit, snapshot.steps);
});
// Call statusStream.close() when this application no longer needs it.GET /cluster-status-stream immediately sends a cluster-status event, then updates:
{ "status": "available", "runningStepIds": [42, 43] }Cluster status is available, degraded, or unavailable, based on Slurm partition availability. It is not a promise that a particular job fits the available resources.
Both streams send heartbeat events with {} after about 30 idle seconds. Ignore SSE comments, including connection padding. Responses use Cache-Control: no-transform and X-Accel-Buffering: no; preserve unbuffered, long-lived connections through your proxy. There are no SSE id: or retry: fields, and Last-Event-ID is not a resume cursor. Request fresh project status after reconnecting when you need to establish current state.
Files, previews, and uploads
GET /step-files?id=… and GET /src-files?id=… list a directory; path selects a relative subdirectory and commit pins the revision. Entries contain name, isDir, size, viewable, seekable, and mimeType. Zip files appear as expandable directories with a size and application/zip MIME type. Append their internal paths to browse or download entries.
Use these routes for file contents:
| Route | Response |
|---|---|
GET /step-files/download?id=…&path=…&commit=… | Full file as application/octet-stream, with attachment Content-Disposition and Content-Length. |
GET /src-files/download?id=…&path=…&commit=… | The equivalent source-file download. |
GET /step-files/raw/<path>?id=…&commit=… | Raw output bytes with a detected MIME type. |
GET /step-files/bundle/<step-id>/<commit>/<path> | Raw output bytes at a path that keeps relative HTML/CSS/image requests in the same pinned bundle. |
GET /src-files/raw?id=…&path=…&commit=… | Raw source bytes, without attachment disposition. This route may appear without a GET operation in the generated reference. |
Encode paths and query values. Raw HTML is executable content: a custom client should sandbox its previews, as Pointy's UI does, rather than inserting it into the application's DOM.
For large text files, use /step-files/seek or /src-files/seek. Supply id, path, optional commit, exactly one of line (1-based) or offset (0-based bytes), and a nonzero signed bytes value with absolute value at most 2 MiB. Positive bytes read forward; negative bytes read backward to the anchor. The JSON response contains content, startOffset, endOffset, startLine, endLine, and eof. Use the returned boundaries: UTF-8 alignment can move a requested boundary. File-listing preview flags distinguish readable files up to 5 MiB from larger seekable files. The download route returns the complete file, not this window.
Editing source files
POST /src-files?id=42&path=script.py creates a file, PUT updates it, and DELETE removes it. POST and PUT take text/plain contents, not a JSON object. Each successful operation commits and pushes separately. Writes have no commit parameter.
Absolute paths and ./.. segments are rejected. Creating an existing path returns 409; updating or deleting a missing file returns 404. Reviewed steps reject mutations. A custom UI must distinguish its local draft from changes already saved by these requests; multi-file saves are not atomic.
Uploading input data
POST /upload?id=42 takes multipart files for an upload step and returns a plain-text confirmation. It stores a fixed-output payload and updates the step's upload hash; it does not write the repository's srcFiles/ directory. Submit at least one file and at most 100 per request. Parser and reverse-proxy limits may constrain request size too; do not assume unlimited uploads.
The upload endpoint does not itself run the resulting step. Pointy's UI follows it with a build request; a custom client should do so too if it wants the output realized. An upload to a reviewed step is refused.
Extras and autocomplete
GET /step-files/extras?id=…&path=<directory>&commit=… returns that folder's metadata object. {} can mean no extras, no metadata for that folder, or a scan queued in the background. Invalid JSON or evaluation errors are failures, not empty metadata. Responses over 10 MiB are rejected. See Extras.
POST /autocomplete?commit=… accepts:
{
"template": "script",
"autocomplete": "packages",
"context": {},
"query": "py",
"limit": 25
}Use hook names actually exported by your repository. limit defaults to 25 and is clamped to 1–100 before calling the hook. Template/hook/context-key identifiers use ASCII letters, digits, _, and -. Query and context values may additionally contain . and +; whitespace is not accepted. The response is an array of suggestion strings.
Recording a review
GET /project-review?project_id=…&commit=… returns a map keyed by step id. Each report has reviewedRevision, reviewedBy, reviewComments, reviewedStatus, reviewedStatusError, comparison, and comparisonDetail. The review is read from current repository state; commit selects the revision being compared with it.
Comparison values are no-review, same-out-path, same-content, different-content, viewed-output-unbuilt, reviewed-output-unbuilt, and unresolvable. Do not collapse missing outputs or evaluation failures into “different”.
POST /step-review?id=42&commit=<viewed-hash> takes:
{ "reviewedBy": "Researcher name", "reviewComments": "Checked the report and inputs." }The name must be nonblank. It is entered text, not a verified identity. The JSON boolean response has a specific meaning: false means the review was recorded; true means the output differs and the existing review was left unchanged. A new review requires an available built output; existing reviews advance only when the output is unchanged. Missing or unresolvable output can return 409.
DELETE /step-review?id=42 removes the review and unlocks the step. GET /step-review-diff?id=42&commit=<viewed-hash> serves the reviewed-vs-viewed diffoscope report as text/html, with a restrictive CSP. It requires both outputs to exist and differ; preparation failures return 500 with text. This raw route is another gap in the generated operation list. Use it only after the comparison reports different-content.
Errors and empty responses
Check HTTP status before decoding. Successful record/configuration reads return JSON, but commit hashes, build logs, uploads, and most error bodies are text. There is no shared JSON error format. Operations documented as returning no content have an empty body; do not unconditionally call response.json() on them. The explicit agent steering endpoint returns 204; other empty responses follow their declared operation status in the generated reference.
Typical failures include 400 for malformed input or invalid download URLs, 404 for missing files/sessions/logs, 409 for review locks or conflicting workflow state, and 500 for evaluation or operational failures. Mapping is not uniform across all handlers: some write and Git failures also return 400 or 500. Keep the error text for diagnosis. Do not automatically replay a mutating request merely because the connection failed; first check whether the write reached the repository.
Embedded agent integration
The /agent/* routes are intentionally excluded from the public OpenAPI operation list. They serve the built-in chat UI and are not a separately versioned public API. If you use them in a custom interface, keep it on a matching backend version and handle the chat's states, not just its replies. The AI Agent guide explains how researchers use the chat.
All POST bodies below are JSON unless the body is shown as “none”. sessionId and turnId are opaque strings, unlike numeric project/step ids.
| Method and route | Body | Result |
|---|---|---|
POST /agent/session | none | New session view. |
GET /agent/sessions | — | Array of session views, newest first. |
GET /agent/session/<id> | — | One session view, including turns. |
POST /agent/turn | { "sessionId": "…", "prompt": "…" } | Started turn; execution continues asynchronously. |
POST /agent/steer | { "sessionId": "…", "prompt": "…" } | Empty 204 after accepted steering or question answer. |
POST /agent/stop | { "sessionId": "…" } | Session view after requesting termination. |
GET /agent/turn/<id>/stream | — | Turn-log SSE stream. |
POST /agent/prepare-apply | { "sessionId": "…" } | Session view with prepared candidate or conflict state. |
POST /agent/confirm-apply | { "sessionId": "…", "targetHead": "…", "candidateHead": "…" } | Applied session view and invalidated project/step ids. |
POST /agent/discard | { "sessionId": "…" } | Session reset to the target branch; chat remains open. |
POST /agent/archive | { "sessionId": "…" } | Closed session retained in history. |
POST /agent/rename | { "sessionId": "…", "name": "…" } | Renamed session view. |
POST /agent/delete | { "sessionId": "…" } | Empty response; permanently removes a session. An active runner must be stopped first. |
GET /agent/usage | — | Session counts: totalSessions, openSessions, runningSessions, appliedSessions, discardedSessions. |
A session view contains session, gitState, and turns. Relevant session fields are sessionId, sessionName, status, baseCommit, activeTurnId, preparedApply, and lastError. Git state includes headCommit, commitLog, branchDiff, and hasAgentCommits. A turn has turnId, turnSessionId, turnPrompt, turnStatus, exit code, timestamps, and turnLog. Finished turn statuses include succeeded, failed, and stopped.
One runner may be active per session. A second turn conflicts; steer the existing runner instead. Separate sessions can run concurrently. Steering can return 409 with runner_not_active, runner_not_ready, runner_stopping, or steering_failed. An empty prompt is 400. Missing sessions are 404; archived sessions refuse new turns. Re-read session state after stop or an error rather than guessing the next state from the button that was clicked.
Turn streaming and questions
Turn SSE events are chunk with { "turnId": "…", "chunk": "…" }, heartbeat with the turn id after about five idle seconds, and done with the turn id. done closes the stream; it does not mean the turn succeeded. Fetch the session for its final status and changeset.
Every connection replays the log from the beginning. There is no offset query parameter or Last-Event-ID resume support. Replace/reset your accumulated log on reconnect, or discard the already-delivered prefix as the built-in client does. Otherwise a reconnect duplicates the conversation.
Chunks contain the backend's labeled log lines, not just assistant prose. In particular, [question] is followed by JSON { "multi": true, "options": ["…"] }, [steering] carries a JSON string, and [question-answered] clears a pending question. Preserve partial lines between chunks. Answer through /agent/steer: a single selection is its 1-based option number; a multi-selection is a comma- or space-separated list of numbers; free text is also accepted. Only one question is pending at a time.
Applying changes safely
Prepare first. Read session.preparedApply.targetHead and candidateHead, display the changeset, then echo those exact values to confirm. An empty candidateHead means a merge is still waiting for conflict resolution and cannot be confirmed. A changed target or candidate invalidates preparation. Some such Git/apply failures return 500 rather than a structured conflict code, so retain the response text and refresh the session.
A successful confirmation returns { "sessionView": ..., "invalidatedProjectIds": [...], "invalidatedStepIds": [...] }. Reload affected records and request fresh statuses. Applying changes to a reviewed step or its source files returns 409 step_reviewed. The backend's path allowlist and review checks still apply to a custom client.
Links into Pointy
These are frontend routes, not API endpoints:
| URL or parameter | Meaning |
|---|---|
/project/1?commit=<hash> | Read-only project at a revision. |
hi=42/results.csv | Highlight a step's output file; hi=42 selects the step. There is no out/ prefix here. |
hi=src/42/script.py | Highlight a source file. |
lines=6-9 | Select 1-based text lines on the highlighted file; a single line can be lines=6. |
compareLeft=out/42/a.csv and compareRight=src/43/b.csv | The two comparison targets. Each output target also needs its compareLeftCommit or compareRightCommit; optional …Mime values select MIME handling. |
/artifact/1/42/<hash>?path=report/index.html | Full-window output preview with a link back to Pointy. |
chat=<session-id>&turn=<turn-id> | Open a chat and optionally highlight a turn on the current page. |
URL-encode path segments and query values. Prefer the built-in Share and Compare controls when you are not writing a client: they select the correct revision and preserve the surrounding project context.
