Skip to main content

REST API

The agent server (mc-agent-api-app) exposes the same use cases the Admin UI runs on, as a plain JSON API under /api. It is what the desktop chat client talks to, and what your own front end would talk to.

mvn -f agents/server/mc-agent-api-app/pom.xml spring-boot:run

Interactive documentation ships with the server: http://localhost:8080/swagger-ui.html, generated from the same annotations the endpoints carry. The pages below cover the parts that need more explanation than a signature.

Authentication​

With authentication off (the default) every request runs as the dev user. With it on, every request needs a bearer token — a personal API token created on the profile page of the Admin UI, or an access token of the Keycloak realm:

curl -H "Authorization: Bearer mct_…" http://localhost:8080/api/agents

The caller is always the authenticated user; no endpoint takes a user id from the request. In Swagger UI, Authorize takes the token. See Authentication.

Agents and sessions​

POST /api/agentscreate an agent
GET /api/agentslist them
GET /api/agents/{id}one agent
PUT /api/agents/{id}partial update — absent fields keep their value
PUT /api/agents/{id}/toolsreplace the tool list
POST /api/agents/{id}/copyduplicate as {name}-copy
DELETE /api/agents/{id}delete
POST /api/sessionsstart a session for the caller — body {agentId, workingDir?, additionalDirs?}; without workingDir the session works in its own directory under the user's home
PUT /api/sessions/{id}/working-dirchange them — body {workingDir, additionalDirs?}; a null or blank workingDir clears it, an absent additionalDirs keeps the current ones, an empty list clears them
GET /api/directories?path=one level of the tree the caller may work in: {root, current, parent, subdirs, truncated} — the root without path
GET /api/sessions?agentId=the caller's sessions for one agent
GET /api/sessions/{id}/historythe persisted messages
DELETE /api/sessions/{id}delete the session

A server serves any number of namespaces. A call names the one it works in in the address — /ns/{namespace}/api/…, so an OpenAI-compatible client simply uses https://host/ns/{namespace} as its base URL — or in an X-Mindconnect-Namespace header; without either it works in the default namespace (mindconnect.namespace, local, MC_NAMESPACE), which every signed-in user may use. The response carries the namespace it worked in under the same header; naming one the caller is not a member of is a 403. No endpoint takes a namespace parameter. Ids travel as their plain value, in paths and in JSON.

Every user-scoped resource — a session and everything under /api/sessions/{id}, an uploaded file, a workspace, the user stream, a transcription job — belongs to the authenticated caller, and a request for someone else's answers 404, exactly like one for an id that does not exist. No request names its user: a userId an older client still sends is ignored. Agents, LLM configs, knowledge-base vector stores and workflows are shared configuration, open to every authenticated caller; a chat's upload store (session-…) answers only to the chat's user.

Concurrent edits​

Agents, LLM configs and vector-store templates carry a version that every save raises. Send the version you read with a save to have it refused when someone else saved in between:

  • PUT /api/agents/{id} — version in the body, next to the fields you change
  • POST /api/llm-configs, POST /api/vector-stores/templates — the object as you read it, version included

A stale version answers 409 Conflict, with expectedVersion and storedVersion in the body: read the object again and decide what to keep. Without a version a save overwrites whatever is stored, as it always did — what an import or a seed wants. A template saved with "version": 0 is refused when one of that name exists already.

OpenAI Responses API​

The admin UI app also serves OpenAI's Responses API, in OpenAI's own wire format, so an official OpenAI SDK talks to a Mindconnect agent after a base_url change and nothing else (mc-agent-api-responses-rest; no separate process).

EndpointPurpose
POST /v1/responsescreate a response — body as OpenAI defines it: model, input, instructions, previous_response_id, stream; with stream: true (in the body, where the SDKs put it) the answer is the SSE event stream
GET /v1/responses/{id}retrieve a response
POST /v1/responses/{id}/cancelcancel one still running
POST /v1/filesupload a file (multipart file, purpose) and get its file_id for input_file / input_image parts
GET /v1/files/{id}, GET /v1/files/{id}/contentthe file object, and its bytes

A user message's content may be a string or a list of parts, as OpenAI defines them: input_text (text), input_image (image_url as an http(s) or data: URL, or file_id; optional detail) and input_file (file_data as a data: URL with filename, file_url, or file_id). A URL is fetched by the server. An image or PDF goes to the model with the message when its config declares the capability; any other document is ingested into the session's store and reached through vector_search. A part of another type is refused with a 400 rather than dropped.

Two words mean something else here than at OpenAI. The client's model is an agent or an LLM config: model: "web-researcher" has that agent answer with its prompt, tools and sub-agents; model: "claude-haiku-default" runs the default agent on that model, as a session-level override that changes nothing for anyone else. A name that is neither is refused rather than quietly answered by something else. The client's conversation is a session, so previous_response_id continues the session that response was made in.

Responses, conversations and files belong to the authenticated caller, as on /api: someone else's response or file answers 404 with {"error": {"type": "not_found", …}}, and a previous_response_id or conversation that is not the caller's is treated like an unknown one.

Not supported: client-side function tools — this runtime executes tools inside the turn instead of handing them back to the caller — skills, and messages with a role other than user in input (the session keeps the earlier turns; continue with previous_response_id).

A browser app on another origin may call this API, like every REST endpoint here: mindconnect.cors.allowed-origins (default *) says which origins.

Chat​

A turn is streamed, not awaited. POST the user's message and read Server-Sent Events until done:

curl -N -X POST http://localhost:8080/api/sessions/$SESSION/chat \
-H 'Content-Type: text/plain' -d 'Which of our tools can read files?'

To send an image or a document with the message, upload it first (POST /api/files, multipart file) and reference it by id in a JSON body — kind is image or file; the file's name, type and size come from the store:

curl -N -X POST http://localhost:8080/api/sessions/$SESSION/chat \
-H 'Content-Type: application/json' \
-d '{"message": "What is in this picture?",
"parts": [{"kind": "image", "fileId": "'$FILE_ID'"}]}'

A vision model sees the picture with the question; a model that does not read images gets a placeholder line instead (see images and documents as message parts). An unknown fileId or kind is a 400.

Each event is one JSON frame with a type:

typecarries
tokentext — a piece of the answer as it is generated
thinkingtext — a piece of the model's reasoning, before the first token of what it led to
asking_llmthe loop is waiting on the model
tool_call_startedtoolName, arguments
tool_call_resulttoolName, result, durationMs
tool_call_failedtoolName, error, durationMs
sub_agent_started / sub_agent_event / sub_agent_done / sub_agent_erroragentName, taskId, subSessionId; sub_agent_event wraps the sub-agent's own frame in inner
reviewing / reviewer_decision / response_revisedthe response-reviewer chain
approval_requesteda tool is waiting for a human — see below
incompleteturnId, pendingApprovals — the turn now waits for every question listed; it goes on once they are answered (see Approvals)
donethe turn ended
errorthe turn failed; text is the message. Only on this endpoint — it is the chat stream's way of reporting a broken turn, not a runtime event

DELETE /api/sessions/{id}/chat cancels a running turn cooperatively: 204 when a turn was signalled, 404 when none is running. The stream still ends with its normal done once the loop reaches the next cancel check.

Reconnecting: the session stream​

A dropped connection used to mean a lost turn — the events had nowhere to go back to. The session now carries the stream, and a client can attach to it at any point, with or without a turn running:

curl -N "http://localhost:8080/api/sessions/$SESSION/stream?afterSeq=0"

The first frame is always the attached frame:

{"type":"attached","firstBufferedSeq":1,"latestSeq":128,
"liveTurnId":"6f1c…","liveRun":0}

It says what the buffer still holds and whether a turn is running (liveTurnId is null when the session is idle). Then the buffered events replay and the stream continues live, each frame carrying its cursor and turn coordinates:

{"seq":129,"turnId":"6f1c…","run":0,"event":{"type":"token","text":"…"}}

Three rules make a client robust:

Remember seq. It is what you hand back as afterSeq after a disconnect. Everything after it is replayed.

Check for a gap. The buffer is finite. If firstBufferedSeq is greater than your afterSeq + 1, events were evicted before you came back — reload GET /api/sessions/{id}/history instead of trusting the tail.

Filter by turnId if you care about one turn only. The stream is the session's, so it spans turns; it stays open across them until you disconnect or the emitter times out. Reattaching with the last seen seq is the intended loop, not an error path.

The user stream​

A session's stream tells you everything about one session, and nothing about the others. A client that shows a list of sessions — or wants to notify someone that an agent is waiting for them — needs the opposite: little about every session, without attaching to any of them. That is the user stream:

curl -N "http://localhost:8080/api/users/me/stream?afterSeq=0"

The path names the caller: me, or the caller's own user id. Any other user answers 404. The first frame is the attached frame with the buffer bounds (firstBufferedSeq, latestSeq); then the buffered events after afterSeq replay and the stream continues live. Every frame is one JSON object with a seq and a type, and the sessionId it is about:

typecarries
session_startedagentDefinitionId, sessionType — a top-level session was opened (sub-agent sessions do not announce themselves); sessionType is chat for a chat and another word for a session a feature opened for itself, which a chat history leaves out
session_titledtitle — the session got its generated title after the first exchange
turn_startedturnId
turn_finishedturnId, outcome — completed, failed or cancelled
approval_requestedcallId, toolName — a tool waits for a human; sessionId is the root session whose chat shows the card, even when a sub-agent asked
approval_answeredcallId, approved — the question was answered, from whichever client

The tokens of a turn are not here. A client that sees turn_started for a session it is showing attaches to that session's stream for the rest; one that is not showing it updates a badge. The reconnect rules are the session stream's: remember seq, check firstBufferedSeq for a gap (the truth to reload from is GET /api/sessions and GET /api/sessions/{id}/approvals), and reattach with the last seen seq when the connection drops.

Approvals​

A tool marked "needs approval" parks its call and the turn waits for a human. The chat stream says so twice: approval_requested in the moment a question is raised, then an incomplete frame listing every question the turn waits on:

{"type":"incomplete","turnId":"6f1c…",
"pendingApprovals":[{"callId":"call_1","toolName":"bash","content":"{…}", …}]}

The questions are also there for a client that connects later:

curl "http://localhost:8080/api/sessions/$SESSION/approvals"

That returns the still-open questions, oldest first — the ones raised by the agent's own tools and the ones bubbled up from sub-agents alike. Each entry carries the callId (the identity of the question), the toolName, and the call JSON in content.

There are two ways to answer. The short one delivers the decision:

curl -X POST "http://localhost:8080/api/sessions/$SESSION/approvals/$CALL_ID?approved=true&scope=once"

scope=once allows exactly this call; scope=session makes it a standing rule for that tool in this conversation, which also releases sibling calls of the same tool that are already parked. 204 means the decision was delivered, 404 means the card was stale and its task is gone. The turn never ended, so the stream that was already open — the chat stream or the session stream — carries it on.

The other one answers and streams the turn on in the same response:

curl -N -X POST "http://localhost:8080/api/sessions/$SESSION/approvals/$CALL_ID/continue?approved=true&scope=once"

It picks the turn up where the stream reported the question, including whatever happened in between, and ends with done — or with another incomplete frame when the turn still waits, for instance on a second call made in parallel. Answer that one the same way. A client that works like this can close its chat stream at the first incomplete frame.

A new message on a session whose turn waits for an approval ends that turn: its parked calls are closed as not approved ("superseded by a new message"), without another round of the model, and the new message starts a new turn.

Working memory​

GET /api/sessions/{id}/memorythe prompt-assembly view: which messages are live, compressed or truncated, plus token accounting
POST /api/sessions/{id}/compresssummarise older turns to reclaim context
DELETE /api/sessions/{id}/messages?fromSeq=&toSeq=drop a range of messages

See working memory for what the strategies do.

Transcription​

Turning a recording into text is a job, not an answer: the audio is stored, a task is queued, and the caller gets an id. A long recording outlasts a comfortable request, providers fail now and then, and the queue behind this already retries and survives a restart — the same reason transcription services hand out job ids rather than holding the connection.

curl -X POST http://localhost:9090/api/transcriptions \
-F "file=@speech.webm" -F "language=en"

The answer is 202 with everything needed to follow the job:

{
"id": "task_774c0642-…",
"status": "queued",
"resultUrl": "/api/transcriptions/task_774c0642-…",
"eventsUrl": "/api/transcriptions/task_774c0642-…/events",
"channelId": "transcription-task_774c0642-…",
"fileId": "file-cbec6f35da40…",
"fileUrl": "/api/files/file-cbec6f35da40…"
}
  • model names an LLM config of type SPEECH_TO_TEXT. An alias of that name is followed, so an operator decides what serves. Omitted, the config named speech-to-text does.
  • language and prompt are optional: the ISO-639-1 code of what is spoken, and context that steers the spelling of names.

Polling. GET /api/transcriptions/{id} answers with the status and, once it is done, the transcript plus the detected language and the recording's length. ?wait=30 holds the request until the job ends or the seconds run out, for a caller who wants the synchronous feel without a polling loop (60 seconds is the cap). Running out is not an error: the answer is then the job as it stands, and the caller asks again.

Streaming. GET /api/transcriptions/{id}/events is Server-Sent Events from the job's channel:

event:queued data:{"status":"queued"}
event:running data:{"status":"running"}
event:delta data:{"status":"delta","text":"The"}
event:delta data:{"status":"delta","text":" quick"}
event:completed data:{"status":"completed","text":"The quick brown fox …"}

A delta carries a fragment, not the whole: append them as they come. How finely they arrive is the model's business — OpenAI's gpt-4o-transcribe models send the text as it forms, whisper-1 answers in one piece and so sends a single delta at the end. The events are the same either way, so client code does not branch on the model. completed always carries the whole transcript and is the authority; a client that only wants the end can ignore every delta.

The stream opens with the job's state as it is now, so attaching late tells you what you missed instead of leaving you waiting, and it closes when the job ends. ?afterSeq= resumes a dropped connection from the last event seen, without a gap — though a very long transcript can push the earliest deltas out of the replay buffer, which is why completed repeats the whole text.

A config can turn the asking off with the provider parameter stream: off, for an endpoint that rejects fields it does not know.

The recording stays. It is an upload in the file store like any other: GET /api/files/{id} for its metadata, …/content for the bytes, and DELETE /api/files/{id} when it has served its purpose. Nothing deletes it for you — a transcript is worth little without the audio behind it, and only the caller knows when that stops being true. The job's fileId and fileUrl come back with the submit answer and with every poll.

A job is readable only by the user who submitted it, and the recording is that user's file. A job from before requesters were recorded stays readable by anyone who has its id.

Beyond chat​

The same server exposes LLM configurations, the file store, session attachments and the vector stores under /api as well. Those follow plain CRUD shapes and are best read in the Swagger UI. One shape worth knowing: GET /api/sessions/{id}/files lists the attached files with fileId, name, mediaType, sizeBytes and chunks — the searchable chunks an ingested file produced, 0 for an image, which goes to the model with the next message instead. DELETE …/files?file= takes the file's id or its name.

A vector store lists entries — stored files and documents of its own — whose chunks live once in the namespace's embedding index. GET /api/vector-stores/stores/{name}/entries lists them, DELETE …/entries/{entityId} takes one off, PUT …/documents/{id} writes a document ({"chunks": [{"text": …, "metadata": {…}}]}), and POST …/search takes query, topK, and optionally entities — {"id": …} plus type, source or container where the id alone is ambiguous — and filters — {"key": …, "op": "EQ"|"IN"|"GTE"|"LTE", "values": […]}, ranges and lists only on metadata fields declared for the entity type (400 otherwise). Hits name their entry: entityType, source, container, entityId.

A file in the store is its uploader's (creator in the file object). GET /api/files lists the caller's own files; by id, a file answers its uploader only, and only the uploader deletes it. Files uploaded before uploaders were recorded have no creator: they stay readable by id — chats and transcripts still point at them — but are not listed and cannot be deleted through the API. Attaching a file by id, to a chat message or a session, works only for a file the caller may read.