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
AgentThreadRequestHandleris what enables the feature: it serves the standard chat routes with thread recording wrapped around the ChatService execution core, plus the thread read routes. Thethreadblock inconfig.yamlonly 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 samesession_idappends to it. user_idis 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
Authoriserthat 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.
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
| Field | Required | Applied | Description |
|---|---|---|---|
session_id | yes | every request | Identifies the thread |
user_id | yes (on the thread handler's chat routes) | at creation | Owning user; also enables user-scoped listing |
group_id | no | at creation only | Caller-defined group/project scope for listing |
thread_name | no | any request | Sets (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_idcan rename its thread.)
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:
- Mount
AgentThreadRequestHandlerin your application (this is what enables the feature) and declare the backend inconfig.yaml, e.g.thread: {type: dynamodb}. - Set the matching Terraform flag, which provisions the backend and injects its connection detail:
| Cloud | Flag | Provisions | Injects |
|---|---|---|---|
| AWS serverless + containerized | create_dynamodb_thread_table | A DynamoDB table (partition session_id, sort sk, TTL on expiry_time, no GSI) | AK_THREAD__DYNAMODB__TABLE_NAME |
| GCP serverless + containerized | create_firestore_thread_collection | Nothing new — reuses the database from create_firestore_database; the collection is created on first write | AK_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.
thread.type runs threads in-memoryAKConfig.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:
threadonly: 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 anattachment_idreference. Use a shared attachment store (in_memory,redis, ordynamodb);storage_type: session_cacheis rejected in thread mode.
multimodal:
enabled: true
storage_type: in_memory
thread:
type: in_memory
Examples
examples/api/thread-openai: text-only threads with a demoAuthoriserexamples/api/multimodal/thread-openai: threads with image/file attachments
