Skip to Content
Polyant is open source under AGPL-3.0 — star us on GitHub.
Admin PanelConversations

Conversations

On this page

The Conversations screen (/organizations/{org}/workspaces/{ws}/conversations) is the browse view across every agent in the active workspace and every channel.

The same list narrowed to one agent is a section of that agent, under its Activity group - same data, one subject. Use the workspace list to search across agents, the agent section when you already know which agent you are investigating.

The analysis table

Managed services. The column catalogue, the per-column filters, the CSV export and the URL-held view described in this section ship in the managed-services build. The open-source build serves the same screen as a fixed table with an agent drop-down and a full-text search, 20 rows a page, ordered by last activity. Everything from Conversation detail onwards is the same in both.

The screen answers which conversations deserve a look, rather than listing the ones that exist. Every dimension of it - which columns, which order, which thresholds, which segment - is a parameter of GET /api/conversations, so the table and an API consumer see the same thing by construction.

Columns

The panel’s catalogue is 25 columns in five groups, made visible through the column picker:

GroupColumns
IdentityConversation, Agent, Channel, Source
VolumeMessages, User messages, Assistant messages, Tokens, Cached tokens, Cache hit rate
CostTotal cost, Conversation cost, Service cost
HealthLLM errors, Error kinds, Tool errors, Hook errors, Governance blocks, Anomalies
TimeAvg latency, Max latency, Avg TTFB, Duration (ms), First message, Last activity

Seven are visible on a first visit: Conversation, Agent, Channel, Messages, Tokens, Total cost, Last activity.

Behind them the engine holds a column registry of 39 entries - the single source of what can be selected, sorted, filtered and exported. The panel surfaces 22 of them by name and computes three of its own from other columns: Agent (returned outside the cols mechanism), Cache hit rate (from cached and total tokens) and Anomalies (from the unanswered, single-turn and truncated flags). The remaining 17 are available to an API caller and not rendered as columns - the title, the summary, the contact, whether a context prompt is set, first and last message timestamps, the LLM/tool/hook call counts beside their failure counts, and the token and cost breakdowns.

Nothing a client sends reaches SQL. cols, sort and a filter’s column name are keys looked up in the registry; an unknown key is dropped rather than rejected, so a stale bookmark degrades to a usable table instead of a 400.

The Health and Time columns are aggregates, and they are computed per request from ai_logs, pipeline_traces, tool_audit_logs, hook_executions and governance_events - not denormalized onto the conversation row. That is why the column selection has a cost: a view with none of them joins none of those five subqueries, rather than all of them.

Note. The registry records the extra permission a column would need - the aggregate columns name analytics:read - but no per-column gate is built, because every permission it names is one a conversation reader already holds. A test keeps that true, so a column gated behind something a reader lacks cannot be added without building the gate.

What the aggregates count

This table is read for triage, so each aggregate is defined by what an operator would act on rather than by what is cheapest to count. Four definitions are worth knowing before reading a red cell:

  • LLM errors and LLM calls count conversation turns only. A service call - memory extraction, a summary - is not a turn anybody waited for, and its failure is invisible to the correspondent. Counting them painted a perfectly answered conversation red. The agent’s Status section uses the same restriction, so the two readers agree by sharing one definition rather than two copies of a string.
  • Total cost and Tokens do not make that split; Conversation cost and Service cost are the two halves. A conversation whose total exceeds its conversation cost spent the difference on extraction and summaries.
  • Governance blocks count blocked turns, not events. One verdict is one row, so a turn stopped by three gates is one block. A neutralized prompt-injection span is excluded: the poisoned text was replaced and the turn proceeded normally, so counting it pointed triage at healthy conversations.
  • Avg latency and Duration are not the same quantity. Avg latency is the mean of a turn’s end-to-end time, from the pipeline traces. Duration is wall-clock from the first message to the last - a conversation with two turns an hour apart has a duration of an hour and a latency of seconds. Its filter is in milliseconds, the unit the column renders.

Filters, order and the segment

Three narrowing controls sit above the table - agent, channel, and one period control that clears back to all time - beside the full-text search and the column picker. Two more narrowings exist on the wire without a control of their own: source and userIdentifier (the contact). They arrive from the URL, which is what makes a link into a narrowed table possible, and each shows as a removable chip once active. The full set is instanceId, channel, source, userIdentifier, the half-open updatedSince/updatedUntil window, and search.

The search is one predicate among the others. It covers the conversation id, the title, the summary and message content, and it composes with everything else - a channel filter or a date window is not dropped while the reader types.

A numeric column carries its own header filter: a threshold with one of four operators (>=, >, <=, <). On the wire that is the repeatable f=column:operator:value, at most 20 of them per request; a non-numeric value, an unknown column, a non-filterable column or an unknown operator is a 400. Every active filter, whatever its source, is shown as a removable chip - and a chip whose column is currently hidden is marked as such, so a table cannot be narrowed by something invisible.

Sorting is by any sortable column, in either direction; an unknown or non-sortable sort falls back to last activity. The panel pages 20 rows at a time and the API caps limit at 100.

Ordering by an aggregate scans the whole filtered segment. Past the Conversations list query timeout, an installation policy (default 15000 ms, accepted range 1000 to 120000), the statement is cut and the API answers 503 {"error":"query_timeout","sort":…} rather than holding a connection open. The screen says which column took too long and offers to limit the window to the last 30 days - an inline banner, not a toast, because the corrective action is a click and not something that should vanish in three seconds. It is the predictable price of that sort over that segment, not a fault.

The view lives in the URL

A filtered, sorted, column-picked table is a link. encodeTableState writes only what differs from the default, so a fresh link stays short; a discrete decision (a filter, a sort, a column toggle) goes through the history, so the back button undoes it, while typing is debounced.

The same view is also remembered per reader, in that browser’s local storage, and re-applied by rewriting the URL on a bare landing. A link that carries a view always wins over the recipient’s saved one: a table someone sends must not be silently overwritten by the recipient’s own columns. Clearing the view forgets it rather than leaving the saved copy to come back on the next visit.

Export CSV

GET /api/conversations/export.csv streams the whole filtered segment, not the page on screen. It shares the list’s predicates, ordering and column resolution, so the file cannot contain a column, an order or a row the table did not select, and its row count is the count the table reports.

  • The cap is 50,000 rows. It rides on every list response as exportRowCap, so the panel disables the button instead of starting a download the engine would refuse. The engine refuses above the cap, so a segment of exactly 50,000 rows exports; and while the cap is unknown - a failed fetch leaves it so - the button stays disabled, because an unknown limit is never “no limit”. Over the cap the request answers 413 {"error":"export_too_large","rowCount","cap"}; the screen says so and asks for a narrower segment.
  • The button stays disabled while the segment’s row count is unknown - loading, or a failed list - because “I cannot tell whether this would be refused” is not the same as “this is fine”.
  • A cell that a spreadsheet would read as a formula is prefixed with an apostrophe, on top of RFC-4180 quoting. A conversation title is text somebody else wrote, and leading whitespace does not exempt it: a title beginning with a tab or a carriage return followed by =cmd|… opens as a formula just the same.
  • The header always wins for tenancy, and this is the one route that also accepts it as a query parameter. A CSV download is a top-level browser navigation, which cannot send the x-workspace-slug header the rest of the panel stamps from the URL; without org/ws the request fell back to the caller’s stored preference and delivered another workspace’s rows. The parameter is consulted only where the header is absent, carries slugs and never ids, and grants nothing: membership of the named workspace is still checked, and a caller who is not a member gets a 403.

GET /api/conversations/{conversationId}/messages accepts around=<messageId> and returns a 40-message window centred on that message, with the conversation’s real total and the absolute offset the window starts at. With the #msg-<messageId> fragment, a link opens the exact turn instead of the top of the thread.

  • A message id that was never persisted - an aborted turn leaves audit rows behind - degrades to the newest page with the real total, and the screen still reports that the linked message was not found.
  • Scrolling up from a centred window loads the page immediately before it, sized to the gap, so no request overlaps a message already on screen.

Lining a conversation up with what it produced

Three routes filter by the conversation, and one by the turn:

RouteParameter
GET /api/audit-logsconversationId
GET /api/instances/{slug}/governance/eventsconversationId, messageId
GET /memoriessourceConversationId

In the panel, a Tool Traces row links to its conversation at the message that issued the call (?around=…#msg-…). It degrades to the conversation itself where the row carries no message id: pre-LLM hooks, the opt-out gate, spawnTask, and every row written before migration 0099, which added tool_audit_logs.message_id and is deliberately not backfilled.

The governance-events section and the saved memories screen accept the same narrowing from their URL (?conversationId=, ?messageId=, ?sourceConversationId=) and show it as a removable chip. Nothing in the panel links into them yet, so today that is a hand-built link or an API call.

Conversation detail

A row navigates to the conversation and its title is a real link, so middle-click, ctrl-click and keyboard focus behave as they do everywhere else. A conversation’s own page keeps its agent’s sidebar rather than falling back to the workspace navigation.

  • Header. The title, with a green dot while the view is live, and one metadata line: channel, agent, message count, when the conversation started, total tokens and estimated cost (each split into the conversation’s own and service work on hover). The ⋯ menu beside the title holds the view switches, copy ID, rename and delete; the conversation id is no longer shown in the header.
  • Origin. The source of the conversation and, where the channel supplies one, the user identifier - a stable caller identity rather than a per-conversation id.
  • Context prompt. Free text injected into every turn’s system prompt for this conversation, typically written by a webhook trigger. It is read only by the detail view: the list and the search queries deliberately do not select it, because it can be arbitrarily long.
  • Message list. Each message displays role (user, assistant, tool, system), content, and timestamp.
  • Steps and tool calls. An assistant turn is rendered as its steps, not as a flat list of calls: each step carries its own text and the tools it called, with their arguments and results. Tool calls appear under the step that issued them.
  • Reasoning. Where the model produced it, thinking is shown per step - signed thinking blocks on Anthropic, summaries on OpenAI. A turn’s message-level reasoning is not repeated when the same content is already on the steps, and a step that only reasoned reads as reasoning-only rather than as “0 tool calls”.
  • Attachments. Photos and PDFs from WhatsApp/Telegram appear inline when that agent has object storage configured - its own bucket, set as the agent’s secrets, not a deployment-wide setting. Without it the agent still sees the file (the bytes reach the model inline); it just cannot be reopened here afterwards. The same holds when an upload fails: the message and the reply are saved without the stored copy, and the turn is not lost.

Steps backfilled from the pre-steps schema are marked legacy: they were synthesised from the old flat {tool, args, result} triples and carry no real timing or reasoning.

Show activity and Live

Two switches sit in the header’s ⋯ menu, which stays open while you flip them.

  • Show activity decides how much of the agent’s work the transcript shows. Off, it reads as a chat: user and assistant messages, attachments, and the badge of a reply a hook or a dev session served. On, it adds the steps, tool calls and reasoning above each reply, the per-message pills (model, provider, tokens, cache, cost, latency), system messages, and the hook executions, placed by time among the messages. The preference is remembered in the browser and shared with the Playground.
  • Live follows the conversation as it happens, on whatever channel it runs. While a turn is in progress the transcript shows it from live activity events: the incoming message, then the agent’s reasoning and tool calls (with Show activity on), then its reply. When the turn’s rows are stored the stored messages take its place. A green dot beside the title says the view is live, and the view keeps the newest activity in sight unless you have scrolled up to read something older.

Live reads GET /api/activity-stream/conversation?conversationId=…&instanceId=<agent slug>, which needs conversation:read on the agent. It is described in Activity Stream → One conversation.

Per-message telemetry

Telemetry is merged into the messages API rather than served separately, and it lands where it belongs: prompt tokens on the user message, and completion tokens, cache counts, model, provider, cost, thinking flag and temperature on the assistant message. Cache, model and cost are null on legacy rows with no linked trace.

An assistant turn also carries:

  • Per-phase latency - context preparation, governance input, tool building, the LLM call, governance output, time to first byte, and the total. The two governance phases are what tell an operator whether a slow turn was the model or the gates.
  • Per-tool timings - name, duration and success per tool call, under toolTimings. This is the only per-tool duration there is.

Note for API consumers. The field is toolTimings, not toolCalls, and its duration is durationMs. The old name collided with the unrelated steps[].toolCalls on the same response. Once a trace exists the field is always an array - [] for a turn that called no tool - and null now means there is no trace at all, which the previous shape could not express.

There is no per-step duration, and there deliberately is not: the value that used to be shown was the turn’s total divided by the number of steps, never a measurement, so reading a slow tool off it was reading an average.

Conversation state

Besides its messages, a conversation carries a state store: a small key/value blob that tools and hooks read and write during a turn. It is how one tool hands a derived value (a resolved customer id, a field mapped in from an HTTP-channel payload) to the next tool or to the next turn, without routing it through the model.

  • The store is read once per turn; writes are buffered and flushed when the turn commits, so a failed turn does not leave half-written state.
  • Concurrent turns on the same conversation merge per key rather than overwriting the whole blob.
  • It is not in the prompt by default. Turning on Conversation state in prompt (Advanced) renders a truncated, read-only view into the turn so the model can see the known facts; the store stays the source of truth either way.
  • _private is a reserved key for data a plugin keeps for an external integration that the model must not see (the candidate names a voice integration passes to speech-to-text, say). Tools and hooks read and write it like any other key and the state API returns it, but it is never rendered into the prompt, whatever the setting above. Other keys starting with _ are left out of the prompt too.
  • A caller can seed the state before the turn: the HTTP channel through its field mapping, and the web API through the request’s context (see Channels → Web/API).

GET /api/conversations/{conversationId}/state returns the current blob (permission conversation:read).

Hook executions

GET /api/conversations/{conversationId}/hooks returns the lifecycle-hook executions recorded for the conversation: event, function, success, duration, and any control signal the hook requested. Use it to confirm whether a hook fired on a given turn and what it did. See Hooks.

Per-message debug payload

When an agent has Debug payload enabled (Advanced), GET /api/conversations/{conversationId}/messages/{messageId}/debug returns the exact LLM request behind that assistant turn - full system prompt, messages array, and tool definitions. It is off by default because it stores personal data at rest.

Rename, reset and deletion

PATCH /api/conversations/{conversationId} changes the title, the conversation id, or both - the Rename action on the detail page. Two rules bound it:

  • The new id must keep the agent’s slug as its prefix. The panel derives an agent’s scope from that prefix and a rename does not move the row to another agent, so a different prefix would make the conversation unopenable.
  • An id already in use is a 409. The rename then rewrites the key across every table that stores it, in one transaction, so nothing is orphaned.

Renaming a channel conversation away from its canonical <slug>:<channel>:<identity> key is how a live thread is reset: the next inbound message recomputes that key, finds nothing, and starts a fresh conversation, while the archived history stays browsable under the new id.

Delete a conversation from its detail page (top-right, Delete button; permission conversation:delete). Deletion cascades in one transaction: messages, AI-gateway call logs, pipeline traces, tool audit logs, governance events, hook executions, memories scoped to the conversation, its state store, its encrypted per-conversation credentials, and its entry in the email thread index.

The same cascade runs unattended when an agent has a retention policy.

Note. There is no bulk delete on the list, and no per-conversation JSON export. The CSV export described above carries the table’s columns for a whole segment, not a conversation’s messages; a single conversation’s full content is read through GET /api/conversations/{conversationId}/messages.

Last updated on