Threads
Multi-turn conversations stored alongside runs.
plico serve stores threads and user messages in the same SQLite database as runs.
Create a thread with POST /v1/threads (HTTP 201), list threads with
GET /v1/threads, and read one at GET /v1/threads/:id. Post a message as
{ "content": "Hello" } to POST /v1/threads/:id/messages, and read its history
with GET /v1/threads/:id/messages.
Messages and runs
When a provider is configured, posting a message reserves and starts a run and
returns its runId. The run uses earlier thread messages as conversation history,
and its successful answer is recorded as an assistant message.
- Only one run per thread may be active. A concurrent message receives
thread.busy(HTTP 409). - Stream run events at
GET /v1/runs/:runId/events/stream. Reconnect withLast-Event-IDor?after=. - Without a configured provider the endpoint returns an actionable error. To store a
message without starting a run, include
"startRun": false.
Titles
Threads get a title once the conversation has a topic. After each of a thread's
first three replies, Plico names it from the words the user used, so a greeting such
as "Hi" leaves it untitled until there is something to name. The rule runs locally
with no model call, and the assistant's text can only confirm a topic, not add one.
PATCH /v1/threads/<thread-id> with { "title": "..." } (up to 200 characters)
renames a thread. A title set that way is never replaced. Studio shows titles in the
thread list and the chat header, where a pencil icon renames the conversation.
Model-written and custom titles
Keyword titles are the default. To have the configured model write the title instead, set
threads: { title: "model" } in plico.config.ts. That costs one short model call after each of a
thread's first three replies. For full control, add threads/title.ts and export a function by
default; it receives the messages ({ role, content }[]) and returns the title, and it takes
priority over the title setting:
export default async (messages: Array<{ role: string; content: string }>) =>
messages[0]?.content.slice(0, 40);The keyword title is stored first, so a model or function that fails, or returns nothing, leaves it in place and never fails the run. A title the user set is never replaced.
Recovery
If a server stops after reserving a run but before starting it, servePlico resumes
it only for the same project and provider account. A model change alone does not
prevent recovery. A mismatched account leaves the message queued rather than sending
its content to a different account.
Embedding
A host that embeds Plico calls createPlico({ projectRoot, databasePath }) from
@plico-ai/server. It opens the thread store, run store, engine and provider from the
project and the database path, recovers interrupted runs, resumes queued ones, and
returns plico.threads (create, get, list, rename, messages, send), plico.runs
(start, get, list, cancel, events) and plico.approvals. plico.runs.events(runId, { after })
is an async iterator over the run's events. Call await plico.close() before the process
exits; it ends open event iterators and closes the thread store. servePlico is
createPlico plus an HTTP listener and closes Plico when the server closes. Readiness
reports a thread and run store mismatch before a run is started.
Mount the API in your backend
plico.fetch(options) returns a web-standard (request) => Promise<Response> handler for Hono, Next.js route handlers, Bun and Deno. plico.node(options) returns a Node (req, res) handler for Express, Fastify and plain http; it reads a body the host already parsed into req.body, such as with express.json(). Both serve the /v1 API. Run your own authentication first, then let scope keep each request to its own data:
const plico = await createPlico({ projectRoot, databasePath });
app.use("/plico", requireLogin, plico.node({
scope: (req) => ({ userId: req.headers.get("x-user-id") ?? "" }),
context: (req) => ({ actor: req.headers.get("x-user-id") ?? "" }),
}));scopeis a JSON object the host computes per request. Plico stamps it on new threads and runs as theirmetadataand refuses every read and action on anything it does not match, with a404, including events, artifacts, cancel and approvals. A client cannot widen it or set its ownmetadata. Ascopefunction that throws or returns anything but an object fails the request with a500.- A scoped mount does not serve the project-level routes
/v1/project,/v1/instructions,/v1/eval,/v1/readinessand/v1/runs/dry. contextis host data for the runs a request starts. Tools read it asctx.context,run.startedrecords it, and an approval sent through the mounted API is recorded with it asapproval.decided.context; a client cannot supply its own.basePathremoves a mount path such as/plicobefore routing, for hosts that do not strip it.publicOriginandallowScriptwork as onservePlico.- A mounted API checks
Content-Typeand refuses cross-origin requests, but not the loopbackHost. The host owns authentication and permissions.
Host data: metadata and context
Plico has no user model. A host that serves its own users passes two kinds of data and enforces access itself.
metadata is a JSON object kept with a thread or run, set when it is created and never
changed. Use it to tag work with your own identifiers, such as a tenant or a ticket.
plico.threads.create({ metadata }) and plico.runs.start({ metadata }) set it, and a
thread's runs carry the thread's metadata. Lists filter by exact match on top-level keys:
plico.threads.list({ metadata: { tenant: "acme" } }), or over HTTP
GET /v1/threads?metadata=<url-encoded JSON> and GET /v1/runs?metadata=<url-encoded JSON>.
A key matches when its value is equal as JSON; a filter with several keys needs all of them.
Metadata that is not a JSON object is rejected with request.invalid.
context is JSON the host passes for one run: plico.threads.send(threadId, text, { context })
or plico.runs.start({ context }). Tools read it as ctx.context. Plico records it on the
run's run.started event, so a run that resumes after a restart, including one queued before
the restart, gets the same context. Because the event log stores it, context must not carry
secrets: pass an identifier and look up credentials in your tool. plico.approvals.decide(runId, requestId, approved, { context }) records the deciding context on approval.decided. The HTTP
API does not accept context; an approval signal sent to POST /v1/runs/<run-id>/signal
has any context dropped, so only the host can say who decided.