Skip to Content

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 with Last-Event-ID or ?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:

threads/title.ts
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") ?? "" }),
}));
  • scope is a JSON object the host computes per request. Plico stamps it on new threads and runs as their metadata and refuses every read and action on anything it does not match, with a 404, including events, artifacts, cancel and approvals. A client cannot widen it or set its own metadata. A scope function that throws or returns anything but an object fails the request with a 500.
  • A scoped mount does not serve the project-level routes /v1/project, /v1/instructions, /v1/eval, /v1/readiness and /v1/runs/dry.
  • context is host data for the runs a request starts. Tools read it as ctx.context, run.started records it, and an approval sent through the mounted API is recorded with it as approval.decided.context; a client cannot supply its own.
  • basePath removes a mount path such as /plico before routing, for hosts that do not strip it. publicOrigin and allowScript work as on servePlico.
  • A mounted API checks Content-Type and refuses cross-origin requests, but not the loopback Host. 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.

On this page