Building Chatbots
Chatbots are an important application of Gen AI, although far from the only use, especially in enterprise.
Unlike many other frameworks, Embabel does not maintain a conversation thread to do its core work. This is a good thing as it means that context compression is not required for most tasks.
If you want to build a chatbot you should use the Conversation interface explicitly, and expose a Chatbot bean, typically backed by action methods that handle UserMessage events.
Core Concepts
Section titled “Core Concepts”Long-Lived AgentProcess
Section titled “Long-Lived AgentProcess”An Embabel chatbot is backed by a long-lived AgentProcess that pauses between user messages.
This design has important implications:
- The same
AgentProcesscan respond to events besides user input - The blackboard maintains state across the entire session
- Actions can be triggered by user messages, system events, or other objects added to the blackboard
- It’s a working context rather than just a chat session
When a user sends a message, it’s added to the blackboard as a UserMessage.
The AgentProcess then runs, selects an appropriate action to handle it, and pauses again waiting for the next event.
Utility AI for Chatbots
Section titled “Utility AI for Chatbots”Utility AI is often the best approach for chatbots. Instead of defining a fixed flow, you define multiple actions with costs, and the planner selects the highest-value action to respond to each message.
This allows:
- Multiple response strategies (e.g., RAG search, direct answer, clarification request)
- Dynamic behavior based on context
- Easy extensibility by adding new action methods
Goals in Chatbots
Section titled “Goals in Chatbots”Typically, chatbot agents do not need a goal. The agent process simply waits for user messages and responds to them indefinitely.
However, you can define a goal if you want to ensure the conversation terminates and the AgentProcess completes rather than waiting forever.
This is useful for:
- Transactional conversations (e.g., completing a booking)
- Wizard-style flows with a defined endpoint
- Conversations that should end after collecting specific information
Key Interfaces
Section titled “Key Interfaces”Chatbot
Section titled “Chatbot”The Chatbot interface manages multiple chat sessions:
public interface Chatbot { ChatSession createSession( User user, OutputChannel outputChannel, String contextId, String conversationId );
ChatSession findSession(String conversationId);}interface Chatbot { fun createSession( user: User?, outputChannel: OutputChannel, contextId: String? = null, conversationId: String? = null, ): ChatSession
fun findSession(conversationId: String): ChatSession?}Context IDs and Session State
Section titled “Context IDs and Session State”The contextId parameter allows you to pre-populate the session’s blackboard with objects from a named context.
This is useful when:
- Users have multiple contexts - A user might have different projects, accounts, or workspaces. Each context can maintain its own state that persists across sessions.
- Resuming prior state - When a user returns, you can restore their previous session state (e.g., user preferences, in-progress work, conversation history from a previous session).
- Pre-loading domain objects - You can populate the blackboard with objects that should always be present, such as the current user’s profile, active subscription, or relevant configuration.
// Create a session with a specific contextChatSession session = chatbot.createSession( user, outputChannel, "project-alpha", // Context ID - loads saved state for this project null);
// Or create an anonymous session without contextChatSession anonymousSession = chatbot.createSession( null, outputChannel, null, null);// Create a session with a specific contextval session = chatbot.createSession( user, outputChannel, contextId = "project-alpha" // Context ID - loads saved state for this project)
// Or create an anonymous session without contextval anonymousSession = chatbot.createSession( user = null, outputChannel = outputChannel)The context mechanism works with `AgentPlatform’s context storage:
- When
createSessionis called with acontextId, the platform looks up any saved objects for that context - Those objects are added to the new session’s blackboard
- As the session runs, changes to the blackboard can be persisted back to the context
- The next time a session is created with that
contextId, the updated state is restored
This enables stateful conversations across sessions without requiring the chatbot to manually track and restore state.
ChatSession
Section titled “ChatSession”Each session represents an ongoing conversation:
public interface ChatSession { OutputChannel getOutputChannel(); User getUser(); Conversation getConversation(); String getProcessId();
void onUserMessage(UserMessage userMessage); boolean isFinished();}interface ChatSession { val outputChannel: OutputChannel val user: User? val conversation: Conversation val processId: String?
fun onUserMessage(userMessage: UserMessage) fun isFinished(): Boolean}Conversation
Section titled “Conversation”The Conversation interface holds the message history and tracks assets:
public interface Conversation extends StableIdentified, AssetView { List<Message> getMessages(); AssetTracker getAssetTracker(); List<Asset> getAssets(); // Combined view of all assets Message addMessage(Message message); UserMessage lastMessageIfBeFromUser();}interface Conversation : StableIdentified, AssetView { val messages: List<Message> val assetTracker: AssetTracker val assets: List<Asset> // Combined view of all assets fun addMessage(message: Message): Message fun lastMessageIfBeFromUser(): UserMessage?}Message types include:
UserMessage- messages from the user (supports multimodal content)AssistantMessage- responses from the chatbot (can include assets)SystemMessage- system-level instructions
Asset Tracking
Section titled “Asset Tracking”Chatbots can track assets—structured outputs like generated documents, search results, or user-created content—at two levels:
Conversation-Level Assets
Section titled “Conversation-Level Assets”The Conversation has an AssetTracker for explicitly tracking assets throughout the session:
// Add an asset to the conversation trackerconversation.getAssetTracker().addAsset(myAsset);
// Get all tracked assetsList<Asset> trackedAssets = conversation.getAssetTracker().getAssets();// Add an asset to the conversation trackerconversation.assetTracker.addAsset(myAsset)
// Get all tracked assetsval trackedAssets = conversation.assetTracker.assetsUse conversation-level tracking when:
- Assets are created by tools or external processes
- Assets should persist across multiple messages
- You want explicit control over what’s tracked
Message-Level Assets
Section titled “Message-Level Assets”AssistantMessage implements AssetView and can include assets directly:
AssistantMessage message = new AssistantMessage( "Here's the report you requested", null, // name null, // awaitable List.of(reportAsset, summaryAsset) // assets);conversation.addMessage(message);val message = AssistantMessage( content = "Here's the report you requested", assets = listOf(reportAsset, summaryAsset))conversation.addMessage(message)Use message-level assets when:
- Assets are directly tied to a specific response
- You want assets to appear alongside the message in the UI
- The asset represents output from that specific interaction
Combined Asset View
Section titled “Combined Asset View”The Conversation.assets property provides a merged view of all assets:
// Gets assets from BOTH the tracker AND all messagesList<Asset> allAssets = conversation.getAssets();// Gets assets from BOTH the tracker AND all messagesval allAssets = conversation.assetsThe merge follows these rules:
- Tracker assets appear first (explicit tracking takes priority)
- Message assets follow in chronological order
- Duplicates are removed by ID (tracker version wins)
This allows flexible asset management:
@Action(canRerun = true, trigger = UserMessage.class)void respond(Conversation conversation, ActionContext context) { // Create an asset from the response Asset resultAsset = createResultAsset(result);
// Option 1: Add to message (appears with this response) var message = new AssistantMessage( "Here's your analysis", null, null, List.of(resultAsset) ); conversation.addMessage(message);
// Option 2: Add to tracker (explicitly tracked) conversation.getAssetTracker().addAsset(resultAsset);
// Either way, it's visible via conversation.getAssets()}@Action(canRerun = true, trigger = UserMessage::class)fun respond(conversation: Conversation, context: ActionContext) { // Create an asset from the response val resultAsset = createResultAsset(result)
// Option 1: Add to message (appears with this response) val message = AssistantMessage( content = "Here's your analysis", assets = listOf(resultAsset) ) conversation.addMessage(message)
// Option 2: Add to tracker (explicitly tracked) conversation.assetTracker.addAsset(resultAsset)
// Either way, it's visible via conversation.assets}Using Assets as Tools
Section titled “Using Assets as Tools”Assets can be exposed to the LLM as tools via their LlmReference:
// Get references from recent assetsList<LlmReference> refs = conversation.mostRecent(5).references();
// Use in a promptvar response = context.ai() .withReferences(refs) // Assets become available as tools .respond(conversation.getMessages());// Get references from recent assetsval refs = conversation.mostRecent(5).references()
// Use in a promptval response = context.ai() .withReferences(refs) // Assets become available as tools .respond(conversation.messages)This enables scenarios like:
- Editing previously generated content
- Combining multiple assets
- Querying structured data from earlier in the conversation
Building a Chatbot
Section titled “Building a Chatbot”Step 1: Create Action Methods
Section titled “Step 1: Create Action Methods”Define action methods in an @EmbabelComponent that respond to user messages using the trigger parameter:
@EmbabelComponentpublic class ChatActions {
private final ToolishRag toolishRag; private final RagbotProperties properties;
public ChatActions( SearchOperations searchOperations, RagbotProperties properties) { this.toolishRag = new ToolishRag( "sources", "Sources for answering user questions", searchOperations ); this.properties = properties; }
@Action(canRerun = true, trigger = UserMessage.class) (1) (2) void respond( Conversation conversation, (3) ActionContext context) { var assistantMessage = context.ai() .withLlm(properties.chatLlm()) .withReference(toolishRag) .rendering("ragbot") .respondWithSystemPrompt(conversation, Map.of( "properties", properties )); context.sendMessage(conversation.addMessage(assistantMessage)); (4) }}@EmbabelComponentclass ChatActions( searchOperations: SearchOperations, private val properties: RagbotProperties) { private val toolishRag = ToolishRag( "sources", "Sources for answering user questions", searchOperations )
@Action(canRerun = true, trigger = UserMessage::class) (1) (2) fun respond( conversation: Conversation, (3) context: ActionContext ) { val assistantMessage = context.ai() .withLlm(properties.chatLlm()) .withReference(toolishRag) .rendering("ragbot") .respondWithSystemPrompt(conversation, mapOf( "properties" to properties )) context.sendMessage(conversation.addMessage(assistantMessage)) (4) }}trigger = UserMessage.class- action is invoked when aUserMessageis the last object added to the blackboardcanRerun = true- action can be executed multiple times (for each user message)Conversationparameter is automatically injected from the blackboardcontext.sendMessage()sends the response to the output channel
Step 2: Configure the Chatbot Bean
Section titled “Step 2: Configure the Chatbot Bean”Use AgentProcessChatbot.utilityFromPlatform() to create a utility-based chatbot that discovers all available actions:
@Configurationclass ChatConfiguration {
@Bean Chatbot chatbot(AgentPlatform agentPlatform) { return AgentProcessChatbot.utilityFromPlatform(agentPlatform); (1) (2) }}@Configurationclass ChatConfiguration {
@Bean fun chatbot(agentPlatform: AgentPlatform): Chatbot = AgentProcessChatbot.utilityFromPlatform(agentPlatform) (1) (2)}- Creates a chatbot using Utility AI planning to select the best action
- Discovers all
@Actionmethods from@EmbabelComponentclasses on the platform
For debugging, you can pass a custom Verbosity configuration:
@BeanChatbot chatbot(AgentPlatform agentPlatform) { return AgentProcessChatbot.utilityFromPlatform( agentPlatform, new InMemoryConversationFactory(), (1) new Verbosity().showPrompts() (2) );}@Beanfun chatbot(agentPlatform: AgentPlatform): Chatbot = AgentProcessChatbot.utilityFromPlatform( agentPlatform, InMemoryConversationFactory(), (1) Verbosity().showPrompts() (2) )- Conversation factory (required when specifying verbosity)
Verbosityconfiguration for debugging prompts
Conversation Storage
Section titled “Conversation Storage”By default, chatbots use in-memory conversations that are lost when the session ends. For production applications, you typically want to persist conversations to a backing store.
Storage Types
Section titled “Storage Types”Embabel supports two conversation storage types via ConversationStoreType:
| Type | Description |
| --- | --- |
| IN_MEMORY | Conversations stored in memory only. Fast and simple, suitable for testing and ephemeral sessions. |
| STORED | Conversations persisted to a backing store (e.g., Neo4j). Requires embabel-chat-store dependency. |
Configuring Persistent Storage
Section titled “Configuring Persistent Storage”To use persistent conversations, inject ConversationFactoryProvider and pass the appropriate factory when creating the chatbot:
@Configurationclass ChatConfiguration {
@Bean Chatbot chatbot( AgentPlatform agentPlatform, ConversationFactoryProvider conversationFactoryProvider) { (1)
ConversationFactory factory = conversationFactoryProvider .getFactory(ConversationStoreType.STORED); (2)
return new AgentProcessChatbot( agentPlatform, user -> createAgent(agentPlatform), factory, (3) // ... other configuration ); }}@Configurationclass ChatConfiguration {
@Bean fun chatbot( agentPlatform: AgentPlatform, conversationFactoryProvider: ConversationFactoryProvider (1) ): Chatbot { val factory = conversationFactoryProvider .getFactory(ConversationStoreType.STORED) (2)
return AgentProcessChatbot( agentPlatform, { user -> createAgent(agentPlatform) }, factory, (3) // ... other configuration ) }}- Inject the
ConversationFactoryProvidervia Spring DI - Get the factory for the desired storage type
- Pass the factory to the chatbot - storage is configured once at creation time
Adding embabel-chat-store
Section titled “Adding embabel-chat-store”To enable persistent storage, add the embabel-chat-store dependency:
<dependency> <groupId>com.embabel.chat</groupId> <artifactId>embabel-chat-store</artifactId></dependency>This provides:
StoredConversationFactory- creates conversations that persist to Neo4jStoredConversation- conversation implementation with async persistence- Message lifecycle events (
MessageEvent) for UI updates - Title generation for conversation sessions
Restoring Conversations
Section titled “Restoring Conversations”To restore a conversation, pass the conversationId when creating a session:
// Restore existing conversation or create new oneChatSession session = chatbot.createSession( user, outputChannel, null, // contextId conversationId (1));
// Messages are already loaded if conversation existedList<Message> history = session.getConversation().getMessages();// Restore existing conversation or create new oneval session = chatbot.createSession( user, outputChannel, contextId = null, conversationId = conversationId (1))
// Messages are already loaded if conversation existedval history = session.conversation.messages- If the conversation exists in storage, it will be loaded automatically. If not, a new conversation is created with this ID.
This allows applications to:
- Resume conversations across server restarts
- Display conversation history to returning users
- Continue multi-turn interactions from where they left off
Step 3: Use the Chatbot
Section titled “Step 3: Use the Chatbot”Interact with the chatbot through its session interface:
// New session (fresh state, generated conversation ID)ChatSession session = chatbot.createSession(user, outputChannel, null, null); (1)
// Session with context (restores blackboard state)ChatSession withContext = chatbot.createSession(user, outputChannel, "user-workspace-123", null); (2)
// Restore existing conversation by IDChatSession restored = chatbot.createSession(user, outputChannel, null, savedConversationId); (3)
// Both context and conversation restorationChatSession full = chatbot.createSession(user, outputChannel, "user-workspace-123", savedConversationId); (4)
session.onUserMessage(new UserMessage("What does this document say about taxes?")); (5)// Response is automatically sent to the outputChannel// New session (fresh state, generated conversation ID)val session = chatbot.createSession(user, outputChannel) (1)
// Session with context (restores blackboard state)val withContext = chatbot.createSession(user, outputChannel, contextId = "user-workspace-123") (2)
// Restore existing conversation by IDval restored = chatbot.createSession(user, outputChannel, conversationId = savedConversationId) (3)
// Both context and conversation restorationval full = chatbot.createSession(user, outputChannel, "user-workspace-123", savedConversationId) (4)
session.onUserMessage(UserMessage("What does this document say about taxes?")) (5)// Response is automatically sent to the outputChannel- Create a new session with fresh blackboard and auto-generated conversation ID
- Load prior blackboard state from the “user-workspace-123” context
- Restore an existing conversation with its message history
- Both: load context state AND restore conversation history
- Send a user message - triggers the agent to select and run an action
How Message Triggering Works
Section titled “How Message Triggering Works”When you specify trigger = UserMessage.class on an action:
- The chatbot adds the
UserMessageto both theConversationand theAgentProcessblackboard - The planner evaluates all actions whose trigger conditions are satisfied
- For utility planning, the action with the highest value (lowest cost) is selected
- The action method receives the
Conversation(with the new message) via parameter injection
This trigger-based approach means:
- You can have multiple actions that respond to user messages with different costs
- The planner picks the most appropriate response strategy
- Actions can also be triggered by other event types (not just
UserMessage)
Dynamic Cost Methods
Section titled “Dynamic Cost Methods”For more sophisticated action selection, use @Cost methods:
@Cost (1)double dynamic(Blackboard bb) { (2) return bb.getObjects().size() > 5 ? 100 : 10; (3)}
@Action(canRerun = true, trigger = UserMessage.class, costMethod = "dynamic") (4)void respond(Conversation conversation, ActionContext context) { // ...}@Cost (1)fun dynamic(bb: Blackboard): Double = (2) if (bb.objects.size > 5) 100.0 else 10.0 (3)
@Action(canRerun = true, trigger = UserMessage::class, costMethod = "dynamic") (4)fun respond(conversation: Conversation, context: ActionContext) { // ...}@Costmarks this as a cost calculation method- Receives the
Blackboardto inspect current state - Returns cost value - lower costs mean higher priority
costMethodlinks the action to the cost calculation method
Prompt Templates
Section titled “Prompt Templates”Chatbots typically use Jinja prompt templates rather than inline string prompts. This isn’t strictly necessary—simple chatbots can use regular string prompts built in code:
var assistantMessage = context.ai() .withLlm(properties.chatLlm()) .withSystemPrompt("You are a helpful assistant. Answer questions concisely.") (1) .respond(conversation.getMessages());val assistantMessage = context.ai() .withLlm(properties.chatLlm()) .withSystemPrompt("You are a helpful assistant. Answer questions concisely.") (1) .respond(conversation.messages)- Simple inline prompt - fine for basic chatbots
However, production chatbots often need longer, more complex prompts for:
- Personality and tone (personas)
- Guardrails and safety instructions
- Domain-specific objectives
- Dynamic behavior based on configuration
For these cases, Jinja templates are the better choice:
var assistantMessage = context.ai() .withLlm(properties.chatLlm()) .withReference(toolishRag) .rendering("ragbot") (1) .respondWithSystemPrompt(conversation, Map.of( (2) "properties", properties, "persona", properties.persona(), "objective", properties.objective() ));val assistantMessage = context.ai() .withLlm(properties.chatLlm()) .withReference(toolishRag) .rendering("ragbot") (1) .respondWithSystemPrompt(conversation, mapOf( (2) "properties" to properties, "persona" to properties.persona(), "objective" to properties.objective() ))- Loads
prompts/ragbot.jinjafrom resources - Template bindings - accessible in Jinja as
properties.persona()etc.
Templates allow:
- Separation of prompt engineering from code
- Dynamic persona and objective selection via configuration
- Reusable prompt elements (guardrails, personalization)
- Prompt iteration without code changes
Resilient Responses with respond
Section titled “Resilient Responses with respond”In a chatbot, it’s critical never to leave the user without a reply.
The respond method on Rendering wraps respondWithSystemPrompt with error handling, so that an LLM or infrastructure failure still returns an AssistantMessage to the user rather than propagating an exception:
var assistantMessage = context.ai() .rendering("ragbot") .respond(conversation, model, error -> { logger.error("Failed to generate response", error); return new AssistantMessage("Sorry, something went wrong. Please try again."); });val assistantMessage = context.ai() .rendering("ragbot") .respond(conversation, model) { error -> logger.error("Failed to generate response", error) AssistantMessage("Sorry, something went wrong. Please try again.") }Template Structure Example
Section titled “Template Structure Example”A typical chatbot template structure from the rag-demo project:
prompts/├── ragbot.jinja # Main entry point├── elements/│ ├── guardrails.jinja # Safety restrictions│ └── personalization.jinja # Dynamic persona/objective loader├── personas/│ ├── clause.jinja # Legal expert persona│ └── ...└── objectives/ └── legal.jinja # Legal document analysis objectiveThe main template (ragbot.jinja) composes from reusable elements:
{% include "elements/guardrails.jinja" %} (1)
{% include "elements/personalization.jinja" %} (2)- Include safety guardrails first
- Then include persona and objective (which are dynamically selected)
Guardrails define safety boundaries (elements/guardrails.jinja):
{# Safety and content guardrails for the ragbot. #}
DO NOT DISCUSS POLITICS OR CONTROVERSIAL TOPICS.Personalization dynamically loads persona and objective (elements/personalization.jinja):
{% set persona_template = "personas/" ~ properties.persona() ~ ".jinja" %} (1){% include persona_template %}
{% set objective_template = "objectives/" ~ properties.objective() ~ ".jinja" %} (2){% include objective_template %}- Build template path from
properties.persona()(e.g., “clause” → “personas/clause.jinja”) - Build template path from
properties.objective()(e.g., “legal” → “objectives/legal.jinja”)
A persona template (personas/clause.jinja):
Your name is Clause.You are a brilliant legal chatbot who excels at interpretinglegislation and legal documents.An objective template (objectives/legal.jinja):
You are an authoritative interpreter of legislation and legal documents.You are renowned for thoroughness and for never missing anything.
You answer questions definitively, in a clear and concise manner.You cite relevant sections to back up your answers.If you don't know, say you don't know.NEVER FABRICATE ANSWERS.
You ground your answers in literal citations from the provided sources.Always use the available tools. (1)- Instructs the LLM to use RAG tools provided via
withReference()
This modular approach lets you:
- Switch personas via
application.ymlwithout code changes - Share guardrails across multiple chatbot configurations
- Test different objectives independently
Advanced: State Management with @State
Section titled “Advanced: State Management with @State”For complex chatbots that need to track state across messages, use @State classes.
State classes are automatically managed by the agent framework:
- State objects are persisted in the blackboard
- Actions can depend on specific state being present
- State transitions drive the conversation flow
Cross-reference the @State annotation documentation for details on:
- Defining state classes
- State-dependent actions
- Nested state machines
Complete Example
Section titled “Complete Example”See the rag-demo project for a complete chatbot implementation including:
ChatActions.java- Action methods responding to user messagesChatConfiguration.java- Chatbot bean configurationRagbotShell.java- Spring Shell integration for interactive testing- Jinja templates for persona-driven prompts
- RAG integration for document-grounded responses
To run the example:
./scripts/shell.sh
# In the shell:ingest ./data/document.mdchat> What does the document say about...