Conversations
ags.conversations is everything you can do to a conversation that has already been recorded — find it, read its transcript, correct how it is labelled, hide it. What it cannot do is create one: conversations come into being through agentsight.conversation(...) on the tracking side, and there is no second way in.
for conversation in ags.conversations.list(has_feedback=True):
print(conversation["conversation_id"], conversation["message_count"])
conversation = ags.conversations.get("wa-3859")
for message in conversation["messages"]:
print(message["sender"], message["content"])Every method here accepts either id — the business conversation_id your application knows, or the integer primary key. See Naming a conversation.
Listing
list() returns a lazy iterator of conversations without their transcripts:
ags.conversations.list(environment="production", is_marked=True)That is a deliberate difference from the raw API, which sends an API key the whole transcript of every row it returns — messages, attachments, action logs and feedback included. Over a busy agent that is hundreds of megabytes to answer "which conversations were marked", so this client asks for the lean shape and offers the other one by name:
for conversation in ags.conversations.list_full(started_at_after="2026-08-01"):
render(conversation["messages"])list_full() is the only way to read messages in bulk — there is no message list of its own on this API. Reach for it when you are exporting or rendering transcripts, and for nothing else.
Soft-deleted conversations are excluded from both unless you ask for them:
ags.conversations.list(include_deleted=True)The filters
Booleans and datetimes are converted for you — pass True, or a datetime, and the right thing goes on the wire. A filter this list does not name is refused locally, because a filter the server ignores returns more rows than you asked for.
| Filter | Selects on |
|---|---|
conversation_id | the exact business id |
customer_id, customer_id__icontains | who the conversation was with |
customer_ip_address | the recorded IP, exactly |
environment (or env) | production or development (prod/dev accepted) |
device, language, name | what was recorded about the conversation |
is_marked | flagged conversations |
include_deleted | include the soft-deleted ones |
has_messages, has_action, has_feedback | conversations where something happened |
action_name | conversations in which a matching tool or task ran — case-insensitive, on part of the name |
message_contains | text inside the transcript |
feedback_sentiment | positive, neutral or negative |
metadata_key, metadata_value, metadata | your own metadata — see below |
started_at_after, started_at_before | when it began |
search | across the searchable fields at once |
ordering | the sort, -started_at style |
Combining them narrows, as you would expect:
unhappy = ags.conversations.list(
environment="production",
feedback_sentiment="negative",
started_at_after=datetime(2026, 8, 1),
ordering="-started_at",
)On usage and spans, environment additionally accepts any slug the agent owns.
Metadata filtering has a wire format of its own. metadata takes key:value pairs — comma-separated to require several at once, dot paths for nested keys — and metadata_key only filters together with a companion metadata_value:
ags.conversations.list(metadata="plan:pro,analysis.room_name:kitchen")
ags.conversations.list(metadata_key="plan", metadata_value="pro")One conversation, whole
conversation = ags.conversations.get("wa-3859")get() returns the record with its messages, attachments, action logs and feedback — asking for one conversation by id is the case where you almost certainly want all of it.
full=False is accepted and sent, but the endpoint underneath answers a detail request the same way either way, so expect the whole conversation regardless. The full distinction is a property of listing, where it is the difference between a lean row and a transcript.
Messages arrive in order, each carrying its sender, content, timestamp, any metadata you recorded, and the action logs, attachments or button click that belong to it.
Attachments
files = ags.conversations.attachments("wa-3859")Every attachment on the conversation, with download URLs. The response is an envelope, not a bare list: conversation (the numeric id), conversation_id, attachment_count, and the rows under attachments.
Download URLs expire after an hour
The URLs are signed and short-lived, so fetch a file when you need it rather than storing the link. A URL you saved yesterday will not work today — ask again, it costs one request.
What metadata has been recorded
Three calls answer "what keys are even in use here", which is what you need before you can filter on metadata sensibly:
ags.conversations.metadata_keys()
# {'keys': ['order_id', 'plan', 'region', 'channel']}
ags.conversations.metadata_values("plan")
# {'key': 'plan', 'values': ['enterprise', 'pro']}
ags.conversations.message_metadata_keys()
# {'keys': ['locale', 'latency_ms']}They are a discovery aid, not analytics: the server reads a recent sample of conversations rather than the full history, so a key last used months ago can be missing from the list. For building a filter UI over data you did not write yourself, that is almost always enough.
Renaming and marking
ags.conversations.rename("wa-3859", "Refund — resolved") # write role
ags.conversations.mark("wa-3859") # write role
ags.conversations.mark("wa-3859", False)A name is a display label, capped at 255 characters and stripped of surrounding whitespace. Marking is the flag the dashboard filters on; mark() sets it, mark(..., False) clears it.
Changing several fields at once
ags.conversations.update( # write role
"wa-3859",
customer_id="user-456",
language="en",
device="mobile",
)update() accepts name, is_marked, customer_id, device, language and metadata. The identity fields are immutable server-side — a conversation's id, its start time and the agent it belongs to are what make it that conversation.
metadata here replaces the whole document, because that is all the underlying change can do. To merge instead, use the next call.
A conversation still open in this process updates itself twice
Every field above except is_marked is also stamped on every span the conversation produces, so if the conversation is still open here — inside a live agentsight.conversation(...) scope — the new value is pushed into that scope as well. Without it the next span would carry the old value and write it straight back over what you just changed.
That echo can only reach a scope in this process, and it needs the business conversation_id to know which scope to look for. Passing an integer primary key for a conversation that is open here and was never listed or resolved by string still writes the row — it just skips the echo. Prefer the string id, which is what you passed to agentsight.conversation() in the first place.
Changing metadata without replacing it
ags.conversations.update_metadata("wa-3859", {"plan": "enterprise"})
ags.conversations.update_metadata("wa-3859", remove=["trial_ends"])Four rules, and they are the same four the tracking-side call follows:
- It merges, it does not replace. Keys you do not name survive.
- The merge is shallow. A nested dictionary is replaced whole rather than merged key by key.
- No value is filtered.
None,False,0and""are stored exactly as given. remove=is the only way to delete a key, and naming one that is not there does nothing.
Two round trips, and not atomic
There is no conditional write underneath this, so it reads what is stored, merges, and writes the result back. Two callers merging into the same conversation at the same moment can lose one of the two updates.
While a conversation is still open in your process, agentsight.update_metadata() is the better call — it needs no fetch and cannot race. This one is for conversations that have already ended.
Like everything else here it blocks, which does not mean it has to sit on your event loop:
background_tasks.add_task(ags.conversations.update_metadata, "wa-3859", data)
await asyncio.to_thread(ags.conversations.update_metadata, "wa-3859", data)Deleting is soft, and there is no other kind
ags.conversations.delete("wa-3859") # write roleThe row survives, flagged as deleted and hidden from listings. It is reversible, and it is readable again the moment you ask for it:
ags.conversations.list(include_deleted=True)
ags.conversations.get("wa-3859") # still answersNothing on this API destroys a conversation. That is a decision, not a missing feature: users delete a conversation to stop seeing it, not to destroy it, and a destructive call reachable by an integration is a hazard no convenience justifies — a hard delete would take the messages, attachments, action logs, spans and token usage with it, unrecoverably. Permanent removal is an operator action against the database. If you need one, ask.
purge() is gone
Earlier versions of this client had a purge() that really did destroy the row. It no longer exists, and the route it called now performs the same soft delete — so code calling it by hand hides a conversation rather than destroying one.
Next
- Feedbacks — what
has_feedbackandfeedback_sentimentare filtering on - Usage — what a conversation cost, by conversation
- Spans — what happened inside a turn, when the transcript is not enough
- Tracking conversations — the other side: how these rows come into being