Skip to main content
Version: Next

Conversation Threads

Agent Kernel supports persistent, named conversation threads: every chat exchange is recorded against its session_id and becomes readable over REST, so UIs can show a user's conversation list and full history across restarts and devices.

Overview

Key Design Decisions

  • Threads are an integration. Mounting AgentThreadRequestHandler is what enables the feature: it serves the standard chat routes with thread recording wrapped around the ChatService execution core, plus the thread read routes. The thread block in config.yaml only selects the store backend and naming model.
  • A thread is keyed by session_id, with no separate thread id. The thread is auto-created on a session's first chat request; every later request with the same session_id appends to it.
  • user_id is required on the thread handler's chat routes; requests without it are rejected with 400. Other surfaces are unaffected.
  • Pluggable storage: in-memory, Redis, Valkey, DynamoDB, Firestore, or Cosmos DB.
  • Optional, pluggable authorization: you supply an Authoriser that validates a Bearer token against your authentication provider; Agent Kernel never authenticates users itself.
  • Streaming included: with execution.mode: stream, the user message is recorded before the stream starts and the assistant message is assembled from the streamed deltas on completion.
Platform scope

Conversation threads are the history mechanism for clients that connect to the agent directly (first-party chat UIs). Messaging integrations (Slack, WhatsApp, Teams, ...) never record AK threads: those platforms own their own conversation history, and AK threads alongside them would create duplicate, divergent state.

Enabling Thread Support

Mount AgentThreadRequestHandler instead of the default handler; it serves /api/v1/chat, /api/v1/chat-multipart, and the thread read routes:

from agentkernel.api import RESTAPI
from agentkernel.thread import AgentThreadRequestHandler

RESTAPI.run(handlers=[AgentThreadRequestHandler()])

Then select the store backend in config.yaml (constructing the handler without a thread block fails fast at startup):

thread:
type: in_memory # in_memory | redis | valkey | dynamodb | firestore | cosmosdb

Chat Request Fields

FieldRequiredAppliedDescription
session_idyesevery requestIdentifies the thread
user_idyes (on the thread handler's chat routes)at creationOwning user; also enables user-scoped listing
group_idnoat creation onlyCaller-defined group/project scope for listing
thread_namenoany requestSets (at creation) or renames (afterwards) the display name and locks it against automatic naming; blank values are ignored. When absent at creation, the name comes from the naming strategy (see below)
curl -X POST http://localhost:8000/api/v1/chat \
-H "Content-Type: application/json" \
-d '{"prompt": "What is the capital of France?", "session_id": "ses-1", "user_id": "alice", "thread_name": "Capitals quiz"}'

Thread Naming

When a thread is created without a thread_name, its display name is generated by the active ThreadNamingStrategy. The built-in default makes a single LiteLLM call at thread creation (model from thread.naming.model, default gpt-4o-mini; API keys are read from the environment) asking for a concise title of at most thread.naming.max_length characters. Gibberish or meaningless first prompts get a generic title ("New conversation") instead of becoming the name. The call happens once per thread, inline on the session's first chat request (~0.5–2s), never on later messages.

Naming never fails thread creation: if litellm is not installed (it is an optional dependency; install it with the thread extra, pip install "agentkernel[thread]"), no API key is present, or the model call errors, the name falls back to a truncated prompt prefix: the first max_length characters, trimmed at a word boundary and suffixed with an ellipsis. The fallback is never silent: a missing litellm is warned about once at startup with the install hint, and every failed naming call logs a warning.

thread:
type: in_memory
naming:
model: gpt-4o-mini # LiteLLM model used to generate thread names
max_length: 80 # max auto-generated name length

Override the strategy by subclassing and registering your subclass once at startup. Override build_instruction to keep the built-in LLM call with your own prompt, or override generate_name for any other logic (optionally calling self._complete(instruction) to reuse the LLM call machinery):

from agentkernel.thread import ConversationThreadManager, ThreadNamingStrategy


class MyNaming(ThreadNamingStrategy):
def build_instruction(self, prompt: str) -> str:
return f"Reply with a three-word title for a conversation starting with: {prompt}"


ConversationThreadManager.set_naming_strategy(MyNaming())

Threads whose name was explicitly supplied (a thread_name on any chat request) are marked name_locked: true in their metadata and are never renamed automatically. No naming call is made for them.

Reading Threads

The thread handler serves two read endpoints alongside the chat routes:

# List threads (metadata only), filtered by user and/or group
curl "http://localhost:8000/api/v1/threads?user_id=alice"

# Get one thread with its message history
curl "http://localhost:8000/api/v1/threads/ses-1"

Both endpoints paginate: pass limit (default 50, max 200) and the opaque cursor returned as next_cursor in the previous page (null on the last page).

Renaming Threads

There is no dedicated rename endpoint. Send thread_name on any later chat request for the same session_id to rename its thread. Only the name changes (every other thread field is fixed at creation), the thread is marked name_locked: true, and blank names are ignored. Resending the same thread_name on every request is cheap: the name is only written when it actually changes.

curl -X POST http://localhost:8000/api/v1/chat \
-H "Content-Type: application/json" \
-d '{"prompt": "And of Italy?", "session_id": "ses-1", "user_id": "alice", "thread_name": "European capitals"}'

Authorization

Thread routes are open until you supply an Authoriser, a small base class you subclass to validate the Bearer token against your own authentication provider and resolve the caller's user_id:

from typing import Optional
from agentkernel.api import RESTAPI
from agentkernel.auth import Authoriser
from agentkernel.thread import AgentThreadRequestHandler


class MyAuthoriser(Authoriser):
def authorise(self, token: str) -> Optional[str]:
# Validate the token with your auth provider (JWT, introspection, API key lookup, ...)
# Return the resolved user_id, or None to reject.
return my_auth_provider.resolve(token)


RESTAPI.run(handlers=[AgentThreadRequestHandler(authoriser=MyAuthoriser())])

With an Authoriser configured:

  • Requests must carry Authorization: Bearer <token>; missing/malformed headers and rejected tokens get 401.
  • Listings are always scoped to the authorised user.
  • Reading another user's thread returns 403. (Renaming flows through the chat request and rides its trust model: whoever can chat on a session_id can rename its thread.)
Open until configured

Without an Authoriser, any caller who knows a session_id can read its thread. Deploy behind network-level access controls until one is configured.

Storage Backends

# Redis
thread:
type: redis
redis:
url: "redis://localhost:6379"
prefix: "ak:thread:"
ttl: 2592000 # seconds; 0 disables expiry

# Valkey (Redis-protocol compatible; requires the `valkey` extra)
thread:
type: valkey
valkey:
url: "valkey://localhost:6379"
prefix: "ak:thread:"
ttl: 2592000 # seconds; 0 disables expiry

# DynamoDB - table needs partition key `session_id` (S) and sort key `sk` (S)
thread:
type: dynamodb
dynamodb:
table_name: "ak-agent-threads"
ttl: 2592000 # item TTL in seconds; 0 disables

# Firestore
thread:
type: firestore
firestore:
collection_name: "ak-agent-threads"
project_id: "my-gcp-project" # optional, inferred from ADC when omitted
database_id: "(default)" # optional
ttl: 2592000 # seconds; 0 disables

# Cosmos DB (Table API, partitioned by session_id, no TTL support)
thread:
type: cosmosdb
cosmosdb:
connection_string: "..."
table_name: "akagentthreads"

Deploying the Thread Store

Deploying threads takes two steps, and they split the same way session does — your application declares which backend, Terraform provisions it and supplies where it lives:

  1. Mount AgentThreadRequestHandler in your application (this is what enables the feature) and declare the backend in config.yaml, e.g. thread: {type: dynamodb}.
  2. Set the matching Terraform flag, which provisions the backend and injects its connection detail:
CloudFlagProvisionsInjects
AWS serverless + containerizedcreate_dynamodb_thread_tableA DynamoDB table (partition session_id, sort sk, TTL on expiry_time, no GSI)AK_THREAD__DYNAMODB__TABLE_NAME
GCP serverless + containerizedcreate_firestore_thread_collectionNothing new — reuses the database from create_firestore_database; the collection is created on first writeAK_THREAD__FIRESTORE__COLLECTION_NAME, __PROJECT_ID, __DATABASE_ID

You do not need to set the table or collection name yourself — Terraform generates it and passes it in.

Setting the flag without declaring thread.type runs threads in-memory

AKConfig.thread is absent until something populates it, and any AK_THREAD__* variable is enough to populate it — but thread.type then falls back to its in_memory default. So a mounted AgentThreadRequestHandler combined with the Terraform flag but without a thread: block in config.yaml runs against the in-memory backend: the provisioned table sits unused and history is lost on every cold start, with no error. Declare thread.type and this cannot happen.

The reverse mistake is safe: declaring thread.type without setting the flag fails loudly at startup — ValueError: AKConfig.thread.dynamodb.table_name must be set — because no connection detail was injected.

Redis and Valkey have no Terraform flag — they reuse whatever cluster create_redis_cluster / create_valkey_cluster already provisions. Declare thread: {type: redis} (or valkey) with the cluster URL in config.yaml, or pass AK_THREAD__REDIS__URL through the generic environment_variables passthrough.

Attachments in Thread Mode

Attachment support is still decided by multimodal.enabled; the thread block alone is text-only:

  • thread only: requests carrying images/files are rejected with 400.
  • thread + multimodal.enabled: true: attachment bytes are saved to the multimodal attachment store and each thread message keeps only an attachment_id reference. Use a shared attachment store (in_memory, redis, or dynamodb); storage_type: session_cache is rejected in thread mode.
multimodal:
enabled: true
storage_type: in_memory

thread:
type: in_memory

Examples

Ready to Ship Your
First Agent?

Free, open-source, Apache 2.0. No licensing costs, no vendor lock-in. Join hundreds of developers building production AI agents with Agent Kernel.

Agent Kernel
Ask Agent Kernel