This guide helps developers quickly understand EDDI's architecture and start building agents.
EDDI is middleware for conversational AI—it sits between your app and AI services (OpenAI, Claude, etc.), providing:
- Orchestration: Control when and how LLMs are called
- Business Logic: IF-THEN rules for decision-making
- State Management: Maintain conversation history and context
- API Integration: Call external REST APIs from agent logic
Every user message goes through a pipeline of tasks:
Input → Parser → Rules → API/LLM → Output
Each task transforms the Conversation Memory (a state object containing everything about the conversation).
Agents aren't code—they're JSON configurations:
Agent (list of workflows)
└─ Workflow (ordered list of pipeline steps)
├─ Parser — input → expressions
├─ Behavior Rules — expressions → actions
├─ API Calls / MCP — actions → outbound calls
├─ LLM — actions → model turn
├─ Output — actions → user-visible messages
└─ Templating — resolves {…} in the output
Each step points at a stored configuration document by eddi:// URI, so the
same rule set or output set can be reused across workflows and agents.
- Java 25 (the version in
pom.xmlis authoritative) - Maven — not needed separately; the repo ships the
./mvnwwrapper (.\mvnw.cmdon Windows) - MongoDB 7 (or PostgreSQL — see Architecture)
- Docker — optional for running from source, required for the quick start below
# Clone repo
git clone https://github.com/labsai/EDDI.git
cd EDDI
# Start EDDI + MongoDB (pin a version, or omit to get :latest)
EDDI_VERSION=6.4.0 docker compose up -d
# Access dashboard
open http://localhost:7070/manageOptional overlays stack on top of the base file — for example, a local LLM on the same Docker network:
docker compose -f docker-compose.yml -f docker-compose.ollama.yml up -d# Clone repo
git clone https://github.com/labsai/EDDI.git
cd EDDI
# Start MongoDB (or use Docker)
# On Mac: brew services start mongodb-community
# On Linux: sudo systemctl start mongod
# Run EDDI in dev mode
./mvnw compile quarkus:dev
# Access dashboard
open http://localhost:7070💡 Secrets Vault: If you plan to store API keys through the Manager UI or use
${vault:...}references, set the vault master key first:export EDDI_VAULT_MASTER_KEY=my-dev-passphrase # Linux/macOS $env:EDDI_VAULT_MASTER_KEY = "my-dev-passphrase" # Windows PowerShellWithout this, the vault is disabled and secret endpoints return HTTP 503. Any passphrase works for local dev. See Secrets Vault for full details.
If you plan to use the Web Search or Weather tools in your agents, you need to set up API keys in your environment or application.properties.
Web Search (Google):
eddi.tools.websearch.provider=googleeddi.tools.websearch.google.api-key=...eddi.tools.websearch.google.cx=...
Weather (OpenWeatherMap):
eddi.tools.weather.openweathermap.api-key=...
See LangChain Documentation for details.
The walkthrough below is a complete, copy-pasteable session against a stock
docker compose up -d instance on http://localhost:7070. Every request and
every response shape here is the one the running server actually produces.
Three things to know before you start, because they surprise everybody once:
- A successful create returns
201with an empty body. The id is in theLocation(andX-Resource-URI) header, as aneddi://URI — not an HTTP URL you can fetch. Take the last path segment as the id:eddi://ai.labs.dictionary/dictionarystore/dictionaries/68a1…?version=1→ id68a1…, version1. - Every store has a
descriptorslisting —GET /<store>/<resource>/descriptors. That is how you find what you created. There is also a cross-type listing,GET /descriptorstore/descriptors?type=<type>, which is handy when you want everything of one kind at once. - Unknown fields are rejected, not ignored. A
400naming the offending key means the payload used an older field name — the message lists the legal ones. This is deliberate: a silently dropped key looks like a successful save and changes nothing.
jq is used below only to pretty-print; it is not required.
EDDI=http://localhost:7070Dictionaries map what users type to expressions, which behavior rules then match on:
curl -i -X POST $EDDI/dictionarystore/dictionaries \
-H "Content-Type: application/json" \
-d '{
"words": [
{ "word": "hello", "expressions": "greeting(hello)", "frequency": 0 },
{ "word": "hi", "expressions": "greeting(hi)", "frequency": 0 }
],
"phrases": []
}'Response: 201 Created, with
Location: eddi://ai.labs.dictionary/dictionarystore/dictionaries/<DICTIONARY_ID>?version=1
Keep <DICTIONARY_ID>. To list every dictionary, or read one back:
curl -s "$EDDI/dictionarystore/dictionaries/descriptors?limit=100" | jq
curl -s "$EDDI/dictionarystore/dictionaries/<DICTIONARY_ID>?version=1" | jqNaming a resource. A resource created over the API has an empty name, so the Manager UI lists it as "Unnamed …". Names live on the descriptor, not on the configuration document — set one with a
PATCH, and the same call works for every resource type in this guide:curl -X PATCH "$EDDI/descriptorstore/descriptors/<ANY_RESOURCE_ID>?version=1" \ -H "Content-Type: application/json" \ -d '{ "operation": "SET", "document": { "name": "Greeting dictionary", "description": "hello / hi" } }'
Rules decide which actions a turn emits:
curl -i -X POST $EDDI/rulestore/rulesets \
-H "Content-Type: application/json" \
-d '{
"behaviorGroups": [
{
"name": "Greetings",
"behaviorRules": [
{
"name": "Welcome",
"conditions": [
{
"type": "inputmatcher",
"configs": {
"expressions": "greeting(*)",
"occurrence": "currentStep"
}
}
],
"actions": ["welcome_action"]
}
]
}
]
}'Response: 201, Location: eddi://ai.labs.rules/rulestore/rulesets/<RULESET_ID>?version=1
curl -s "$EDDI/rulestore/rulesets/descriptors?limit=100" | jq
behaviorRulesis the canonical name: it is what a read returns, and what the shipped reference config and the ZIP fixtures use.rulesis still accepted on write for older clients. Before 6.4.0 a read answeredrulesregardless of what you posted, which is why a rule set created over the API showed up empty in the Manager.
Output maps an action to what the user sees. valueAlternatives is a list of
typed output items, not a list of strings — a bare string is rejected:
curl -i -X POST $EDDI/outputstore/outputsets \
-H "Content-Type: application/json" \
-d '{
"outputSet": [
{
"action": "welcome_action",
"timesOccurred": 0,
"outputs": [
{
"valueAlternatives": [
{ "type": "text", "text": "Hello! How can I help you today?" }
]
}
]
}
]
}'Response: 201, Location: eddi://ai.labs.output/outputstore/outputsets/<OUTPUT_ID>?version=1
Besides text, an output item may be image, quickReply, inputField,
applicationLink, button, agentFace or other. See
Output Configuration.
A workflow is the ordered list of pipeline steps — the workflowSteps array.
Each step names a lifecycle task by eddi:// URI; a step that needs a
configuration document points at it through config.uri, and the parser takes
its dictionaries through extensions instead:
curl -i -X POST $EDDI/workflowstore/workflows \
-H "Content-Type: application/json" \
-d '{
"workflowSteps": [
{
"type": "eddi://ai.labs.parser",
"config": {},
"extensions": {
"dictionaries": [
{
"type": "eddi://ai.labs.parser.dictionaries.regular",
"config": {
"uri": "eddi://ai.labs.dictionary/dictionarystore/dictionaries/<DICTIONARY_ID>?version=1"
}
}
],
"corrections": []
}
},
{
"type": "eddi://ai.labs.rules",
"config": {
"uri": "eddi://ai.labs.rules/rulestore/rulesets/<RULESET_ID>?version=1"
}
},
{
"type": "eddi://ai.labs.output",
"config": {
"uri": "eddi://ai.labs.output/outputstore/outputsets/<OUTPUT_ID>?version=1"
}
},
{ "type": "eddi://ai.labs.templating", "config": {} }
]
}'Response: 201, Location: eddi://ai.labs.workflow/workflowstore/workflows/<WORKFLOW_ID>?version=1
Step order is execution order. The available step types:
Step type |
Purpose | Configuration |
|---|---|---|
eddi://ai.labs.parser |
Input → expressions | dictionaries/corrections via extensions |
eddi://ai.labs.rules |
Behavior rules → actions | config.uri → rule set |
eddi://ai.labs.property |
Slot-filling / properties | config.uri → property setter |
eddi://ai.labs.apicalls |
Outbound HTTP calls | config.uri → API calls |
eddi://ai.labs.mcpcalls |
MCP tool calls | config.uri → MCP calls |
eddi://ai.labs.llm |
LLM interaction | config.uri → LLM config |
eddi://ai.labs.output |
Actions → user-visible output | config.uri → output set |
eddi://ai.labs.templating |
Resolves {…} in the output |
none |
Add
eddi://ai.labs.templatinglast whenever an output or system prompt contains{properties.x}-style placeholders. Without it the expressions are never resolved. It is harmless when there is nothing to resolve, so the examples keep it.
eddi://ai.labs.behaviorandeddi://ai.labs.httpcallsare accepted as aliases ofai.labs.rulesandai.labs.apicalls; new configurations should use the names in the table.
An agent is a list of workflows — the field is workflows:
curl -i -X POST $EDDI/agentstore/agents \
-H "Content-Type: application/json" \
-d '{
"workflows": [
"eddi://ai.labs.workflow/workflowstore/workflows/<WORKFLOW_ID>?version=1"
]
}'Response: 201, Location: eddi://ai.labs.agent/agentstore/agents/<AGENT_ID>?version=1
Give it a name, or the Manager will list it as "Unnamed Agent":
curl -X PATCH "$EDDI/descriptorstore/descriptors/<AGENT_ID>?version=1" \
-H "Content-Type: application/json" \
-d '{ "operation": "SET",
"document": { "name": "Greeter", "description": "Says hello" } }'
packagesis still accepted as an alias forworkflows, so a payload using the old name is stored rather than rejected. It is deprecated — writeworkflows.
An agent must be deployed to an environment before it can hold a conversation.
Pass waitForCompletion=true and the call returns the final status instead of
a bare 202 Accepted:
curl -s -X POST \
"$EDDI/administration/production/deploy/<AGENT_ID>?version=1&waitForCompletion=true" | jq{
"status": "READY",
"agentId": "<AGENT_ID>",
"version": 1,
"environment": "production"
}Any status other than READY — typically ERROR — means the agent's workflow
references something that does not resolve. Check the server log, then
re-deploy. The status can be re-read at any time:
curl -s "$EDDI/administration/production/deploymentstatus/<AGENT_ID>?version=1" | jqStarting a conversation and sending a message are two separate calls. The
start call takes no message body; it creates the conversation and returns its id
in the Location header:
curl -i -X POST \
"$EDDI/agents/<AGENT_ID>/start?environment=production&userId=test-user"HTTP/1.1 201 Created
Location: eddi://ai.labs.conversation/conversationstore/conversations/<CONVERSATION_ID>
Then talk to the conversation, not the agent. Plain text is the simplest form:
curl -s -X POST "$EDDI/agents/<CONVERSATION_ID>" \
-H "Content-Type: text/plain" \
--data 'hello' | jq{
"conversationId": "<CONVERSATION_ID>",
"agentId": "<AGENT_ID>",
"agentVersion": 1,
"userId": "test-user",
"environment": "production",
"conversationState": "READY",
"conversationOutputs": [
{
"actions": ["welcome_action"],
"output": [
{ "type": "text", "text": "Hello! How can I help you today?", "delay": 0 }
]
}
],
"conversationProperties": {},
"conversationSteps": []
}The same endpoint accepts JSON when you want to pass context alongside the message:
curl -s -X POST "$EDDI/agents/<CONVERSATION_ID>" \
-H "Content-Type: application/json" \
-d '{ "input": "hello",
"context": { "language": { "type": "string", "value": "en" } } }' | jqTo start a conversation with an initial context, POST that same context map — and only a context map — to the start endpoint:
curl -i -X POST "$EDDI/agents/<AGENT_ID>/start?environment=production&userId=test-user" \
-H "Content-Type: application/json" \
-d '{ "userName": { "type": "string", "value": "Ada" } }'Ollama is used here because it needs no API key. Everything below is identical
for the other providers except type and the credential — see
LLM Integration for the full list.
If EDDI runs in Docker and Ollama runs on your host, localhost inside the
container is the container. Either use http://host.docker.internal:11434, or
run Ollama as a compose service with
docker compose -f docker-compose.yml -f docker-compose.ollama.yml up -d, which
puts it on the same network as http://ollama:11434.
curl -i -X POST $EDDI/llmstore/llms \
-H "Content-Type: application/json" \
-d '{
"tasks": [
{
"id": "assistant",
"type": "ollama",
"description": "Local Ollama chat",
"actions": ["send_to_ai"],
"parameters": {
"baseUrl": "http://host.docker.internal:11434",
"model": "llama3.2:3b",
"temperature": "0.7",
"systemMessage": "You are a helpful assistant.",
"addToOutput": "true"
}
}
]
}'Response: 201, Location: eddi://ai.labs.llm/llmstore/llms/<LLM_ID>?version=1
parametersis a free-form map, but a key no provider reads is logged as a warning at build time rather than applied.systemMessageandaddToOutputare read by the pipeline;model,baseUrl,temperature,maxTokens,topPandtopKare read by the Ollama builder.
For a hosted provider, put the credential in the vault rather than in the configuration document:
"parameters": { "apiKey": "${vault:openai-key}", "modelName": "gpt-4o" }Insert an eddi://ai.labs.llm step into workflowSteps, before
eddi://ai.labs.output:
{
"type": "eddi://ai.labs.llm",
"config": {
"uri": "eddi://ai.labs.llm/llmstore/llms/<LLM_ID>?version=1"
}
}Update the workflow in place (this creates version 2):
curl -i -X PUT "$EDDI/workflowstore/workflows/<WORKFLOW_ID>?version=1" \
-H "Content-Type: application/json" \
-d '{ "workflowSteps": [ /* … the full list, with the llm step added … */ ] }'Then point the agent at the new workflow version and deploy again — a deployed agent version is immutable, so a config change always means a re-deploy.
The LLM task runs when an action it listens for is emitted. Add a rule whose
actions contains send_to_ai:
{
"name": "Ask AI",
"conditions": [
{
"type": "inputmatcher",
"configs": { "expressions": "unknown(*)", "occurrence": "currentStep" }
}
],
"actions": ["send_to_ai"]
}Now anything the dictionary does not recognise is handed to the model, while
greeting(*) still takes the cheap rule-based path.
Let's trace what happens when a user says "hello":
POST /agents/{agentId}/start?environment=production → 201, conversation id in Location
POST /agents/{conversationId} (text/plain) "hello" → the turn below
- Validates agent ID
- Creates/loads conversation memory
- Submits to ConversationCoordinator
- Ensures sequential processing (no race conditions)
- Queues message for this conversation
Parser Task:
Input: "hello"
→ Parses using dictionary
→ Output: expressions = ["greeting(hello)"]
→ Stores in memory
Behavior Rules Task:
Reads: expressions = ["greeting(hello)"]
→ Evaluates rules
→ Rule matches: "if greeting(*) then welcome_action"
→ Output: actions = ["welcome_action"]
→ Stores in memory
Output Task:
Reads: actions = ["welcome_action"]
→ Looks up output template for "welcome_action"
→ Output: "Hello! How can I help you today?"
→ Stores in memory
- Memory saved to MongoDB
- Response returned to user
The state object passed through the pipeline:
IConversationMemory memory = ...;
// Read user input
String input = memory.getCurrentStep().getLatestData("input").getResult();
// Store parsed data
memory.getCurrentStep().storeData(
dataFactory.createData("expressions", expressions)
);
// Access conversation properties
String userName = memory.getConversationProperties().get("userName");Interface all tasks implement:
public class MyTask implements ILifecycleTask {
@Override
public void execute(IConversationMemory memory, Object component) {
// 1. Read from memory
String input = memory.getCurrentStep().getLatestData("input").getResult();
// 2. Process
String result = process(input);
// 3. Write to memory
memory.getCurrentStep().storeData(
dataFactory.createData("myResult", result)
);
}
}Ensures messages are processed in order:
// Messages for same conversation execute sequentially
coordinator.submitInOrder(conversationId, () -> {
processMessage(memory, input);
return null;
});Only call LLM for complex queries:
{
"behaviorRules": [
{
"name": "Simple Greeting",
"conditions": [
{ "type": "inputmatcher", "configs": { "expressions": "greeting(*)" } }
],
"actions": ["simple_greeting"]
},
{
"name": "Complex Question",
"conditions": [
{ "type": "inputmatcher", "configs": { "expressions": "question(*)" } }
],
"actions": ["send_to_ai"]
}
]
}Fetch data, then ask LLM to format it:
{
"behaviorRules": [
{
"name": "Weather Query",
"conditions": [
{
"type": "inputmatcher",
"configs": { "expressions": "entity(weather)" }
}
],
"actions": ["httpcall(weather-api)", "send_to_ai"]
}
]
}The LLM receives the API response in memory and can format it naturally.
Use context passed from your app:
The start endpoint's JSON body is the context map — there is no input
field on it, because starting a conversation and sending a message are separate
calls:
# 1. Start with initial context
curl -i -X POST "http://localhost:7070/agents/<AGENT_ID>/start?environment=production" \
-H "Content-Type: application/json" \
-d '{
"userName": { "type": "string", "value": "John" },
"userId": { "type": "string", "value": "user-123" }
}'
# 2. Send the message (context may also be supplied per turn)
curl -s -X POST "http://localhost:7070/agents/<CONVERSATION_ID>" \
-H "Content-Type: application/json" \
-d '{ "input": "What is my name?" }'Access in an output template — and remember the eddi://ai.labs.templating
step, or the placeholder is never resolved:
Hello {context.userName}!
- Architecture Overview - Deep dive into design
- Behavior Rules - Master decision logic
- HTTP Calls - Integrate external APIs
- LLM Integration - Configure LLMs (12 providers)
- Output Configuration - Message and quick-reply types
- Human-in-the-Loop - Gate an agent's writes on human approval
Visit http://localhost:7070/manage to:
- Create agents visually
- Test conversations interactively
- Browse configurations
- Monitor deployments
docs/agent-configs/rule-based-reference/ is a complete, working rule-based
agent — a conversational wizard that provisions another agent over EDDI's own
REST API. It is the canonical reference for behavior-rule patterns, property
setters capturing free text, HTTP call templates and quick replies. Two unit
tests sweep it, so the config documents it supplies keep parsing and saving —
note that descriptors and unmapped filenames are counted as skipped rather than
checked, so a green sweep is not a claim about every file in the directory.
Create a custom lifecycle task:
@ApplicationScoped
public class MyCustomTask implements ILifecycleTask {
@Override
public TaskId getId() {
return new TaskId("ai.labs.mycompany.customtask");
}
@Override
public String getType() {
return "custom_processing";
}
@Override
public void execute(IConversationMemory memory, Object component) {
// Your logic here
}
}Register it in CDI and it becomes available as an extension!
400with a message naming a field — the payload used a key the model does not declare, or put the wrong kind of value in one that it does. The message names the JSON path and the legal fields.404on deploy — the agent id or version does not exist.201but nothing appears in the Manager — the resource was created with an empty name. Set one viaPATCH /descriptorstore/descriptors/{id}?version=1.
- Deployment status:
GET /administration/production/deploymentstatus/{agentId}?version=1— anything other thanREADYmeans the workflow did not load. - Conversation state:
GET /agents/{conversationId}/status - Full memory snapshot (what each pipeline step actually produced):
GET /agents/{conversationId}?returnDetailed=true - Server log:
docker compose logs -f eddi, orGET /administration/logs
- Confirm the parser produced the expression you are matching on — the
expressions:parsedentry of the detailed snapshot above shows exactly what it emitted, andbehavior_rules:success/behavior_rules:failshow which rules matched. - The dictionary has to be wired into the parser step's
extensions, not referenced as a step of its own. actionmatcherwith a comma-separated list means AND (a contiguous sublist), not OR. Use aconnectorwith"operator": "OR"for alternatives.occurrence: "anyStep"matches anywhere in the conversation.
- The behavior rule has to emit the action the LLM task lists in its
actions. - The
eddi://ai.labs.llmstep has to be in the workflow the deployed agent version points at — adding it to a newer workflow version does nothing until the agent is re-pointed and re-deployed. - Check the credential resolves. A
${vault:...}reference needsEDDI_VAULT_MASTER_KEYset, or the vault is disabled.
- From inside the
eddicontainer,localhostis the container. Usehttp://host.docker.internal:11434, or thedocker-compose.ollama.ymloverlay andhttp://ollama:11434. - Reasoning models (gemma3n, deepseek-r1, qwen3 …) think before answering, and
the reasoning is not part of the streamed content — the window stays silent
for as long as that takes. Set
"think": "false"in the task'sparametersto turn it off, or"returnThinking": "true"to surface it.
- Ensure MongoDB is running and the connection string is right.
scope: "step"is cleared at the end of the turn,conversationlives for the session,longTermsurvives across conversations.
- Documentation: https://github.com/labsai/EDDI/tree/main/docs
- GitHub: https://github.com/labsai/EDDI
- Issues: https://github.com/labsai/EDDI/issues
EDDI's power comes from its configurable pipeline architecture:
- Agents are JSON configurations, not code
- Everything flows through Conversation Memory
- Tasks are pluggable and reusable
- LLMs are orchestrated, not just proxied
Start simple, then add complexity as needed. The architecture scales from basic agents to sophisticated multi-API workflows.