Save a conversation from another source

Copy a Slack thread, an email chain, or any other transcript into a user's Astro chat history
View as Markdown

An agent can copy a conversation that happened somewhere else into a user’s Astro AI chat history. The copy appears in that user’s conversation list under its title, alongside the conversations they had in the chat, and opens like any other one.

The API takes a list of turns and nothing platform-specific. Where the transcript came from is the agent’s business: a Slack thread, an email chain, a support ticket, a transcript your own service already stores. If your agent runs on the Astro messaging sidecar, this is already available to it. There is no capability to switch on and no separate service to call.

Reading this from StreamOptions requires @astropods/adapter-core 0.10.0 or later for Node, or astropods-adapter-core 0.7.0 or later for Python. On an earlier version the field is absent.

Save a transcript

StreamOptions.saveConversation (save_conversation in Python) takes the turns and returns the conversation the copy landed on.

async stream(prompt, hooks, options) {
const res = await options.saveConversation!({
userId: options.userId,
idempotencyKey: `slack:${channelId}:${threadTs}`,
title: "Deploy is stuck in pending",
sourceLabel: "#eng-support",
sourceUrl: `https://slack.com/archives/${channelId}/p${threadTs}`,
messages: [
{ role: "user", author: "Ada", content: "the deploy is stuck", timestamp: new Date() },
{ role: "assistant", content: "checking the scheduler now" },
],
});
hooks.onChunk(`Saved. Status: ${res.status}.`);
hooks.onFinish();
}

saveConversation is optional on StreamOptions, because a caller that drives the gRPC stream itself may not provide it. Guard on it, or assert it, before calling.

Input

NodePythonRequiredNotes
idempotencyKeyidempotency_keyyesStable per source conversation and user. See Saving twice.
messagesmessagesyesThe turns, oldest first.
userIduser_idnoAstro user id that owns the copy. Defaults to this turn’s user. Must start with user_.
titletitlenoShown in the conversation list. A title the user has since renamed is left alone.
sourceLabelsource_labelnoWhere the transcript came from, for example #eng-support. Recorded with the copy.
sourceUrlsource_urlnoLink back to the source. Recorded with the copy.
onConflicton_conflictnoSKIP (default), REPLACE, or APPEND. See Saving twice.

Each turn:

NodePythonRequiredNotes
roleroleyesuser or assistant. Any other value is rejected.
contentcontentyesThe text of the turn.
authorauthornoOriginal sender’s display name. Set it when the transcript has several speakers, or every turn renders as the owner’s own.
timestamptimestampnoWhen the turn happened at the source. Defaults to now.

Give every copy a title. The conversation list identifies a copy by its title, so a save with no title arrives as an untitled conversation.

The copy must be addressed to a resolved Astro identity: userId has to start with user_. A raw platform id, such as a Slack U…, is refused, because it would write a conversation no session can open. Resolve the sender to an Astro user first, and tell the user when you cannot.

Saving twice

The idempotencyKey is what makes a save repeatable. The platform derives the conversation id from (userId, idempotencyKey), so the same key always addresses the same copy. Key it on the source conversation, not on the message that triggered the turn: slack:C123:1699.0001, zendesk:ticket-4417.

Re-read the whole source and send it again under the same key to keep a copy current. The refreshed copy reflects the source, including edits and deletions.

onConflict decides what happens to a copy that already exists.

ValueBehavior
SKIPDefault. Refreshes a copy the user has not touched, and leaves a copy they replied in alone.
REPLACEOverwrites, discarding turns the user typed into the copy.
APPENDAdds these turns after whatever is already there.

The platform will not silently destroy turns it did not write. Under SKIP, a copy the user has replied in comes back as SKIPPED_DIVERGED and is left as it was. Choosing REPLACE there is the agent stating that intent.

Status

The status is the point of the call. Await it.

StatusMeaningWhat agents usually do
CREATEDFirst save, new conversation.Tell the user where it went.
REPLACEDExisting copy refreshed.Nothing.
APPENDEDTurns added after what was there.Nothing.
SKIPPED_DIVERGEDThe user has replied in the copy, and SKIP left it alone.Re-send with APPEND so their notes survive and new turns still arrive.
SKIPPED_DELETEDThe user deleted the copy. It is never recreated.Stop saving this source. Deleting the copy is how a user opts out.
SKIPPED_CONFLICTThe derived id belongs to another user’s conversation.Report the failure. Nothing was changed.

A deleted copy staying deleted is deliberate: an agent that saves on every source message would otherwise resurrect a conversation the user threw away.

Read the source thread first

An agent that mirrors a thread needs the whole thread, and the prompt only carries the message that triggered the turn. StreamOptions.getThreadHistory (get_thread_history) returns the source thread, hydrated from the platform so edits and deletions are reflected.

const thread = (await options.getThreadHistory?.(200)) ?? [];
const messages = thread.map((m) => ({
role: "user" as const,
author: m.user?.username ?? "",
content: m.content,
timestamp: new Date(Number(m.timestamp?.seconds ?? 0) * 1000),
}));

Platform ids are opaque, and a transcript copied verbatim carries them. Slack mention tokens such as <@U07UVWXYZ> stay in the text unless the agent rewrites them, and they read as noise in the chat. The thread itself usually holds the answer: each message carries its poster’s resolved username, so build a map of id to name from the thread and substitute before saving. Prefer leaving a turn unattributed over setting author to a raw platform id.

Why it is not automatic

Running the messaging sidecar gives your agent this API, but the sidecar will not save conversations on its own, because it cannot answer the questions the call requires. Which of the many conversations your agent sees is worth keeping. Whose history it belongs to. What identifies it, so a later save updates that copy rather than creating a second one. Whether a copy the user has since edited should be overwritten or appended to.

Only the agent can answer those, so a save happens when an agent asks for one. A user gets a conversation in their history because an agent was built to put one there.

Next steps