Cameleer for AI agents
Cameleer is the action layer for AI agents: the model decides what, a tested route decides how. It feeds events to models, routes on their answers, and turns agent tool calls into reliable actions with validation, idempotency, retries, sagas, human approval, rate limits, fallbacks and an audit log.
An agent decides what to do; a route does it. This cookbook shows eight patterns, each a
complete route file in examples/agents/ that runs end to end in the
test suite (crates/cameleer-main/tests/docs_guides.rs) against an OpenAI-compatible mock, the
embedded Kafka broker, an Elasticsearch emulator and Slack and payment API mocks.
The routes use the openai component, which works with OpenAI and
every OpenAI-compatible server: Azure OpenAI gateways, Ollama, vLLM, LM Studio and LiteLLM.
Point it at another server with OPENAI_BASE_URL and OPENAI_MODEL. The component reads the
API key from the OPENAI_API_KEY environment variable, so the key never appears in a route.
AWS Bedrock (aws-bedrock), Azure OpenAI (azure-openai) and Google Vertex AI
(google-vertexai) work the same way in these patterns; see AI components.
| pattern | file | what it shows |
|---|---|---|
| Classify and route | llm-classify-route.camel.yaml | an LLM label drives a content-based router, with a fixed list of trusted labels |
| Summarize | llm-summarize-pipeline.camel.yaml | map-reduce summaries of long documents |
| RAG ingestion | rag-ingest.camel.yaml | chunk, embed and index documents in Elasticsearch |
| Tools over REST | agent-tool-api.camel.yaml | schema validation, idempotency, retries and an audit log for agent tool calls |
| Human approval | human-approval.camel.yaml | park risky actions until a person approves them |
| Guard LLM output | llm-output-guard.camel.yaml | validate model JSON against a schema, re-ask once |
| Rate limits and breakers | llm-rate-limit-breaker.camel.yaml | throttle and circuit breaker around a model API |
| Multi-model fallback | llm-multi-model-fallback.camel.yaml | fail over from a hosted model to a local one |
Classify with an LLM and route on the answer
Tickets arrive over HTTP. The model labels each one, and a choice sends the original ticket
to a Kafka topic per label. Two details make this safe to run:
- The ticket is kept in an exchange property and put back after the model call, so the topic receives the ticket, not the model's answer.
- The router compares the label against a fixed list. Anything else, including a ticket that
tries "ignore previous instructions", goes to the general topic. Never use the model's
answer directly in
toDor as a topic name.
- route:
id: classify-ticket
from:
uri: "platform-http:/tickets?httpMethodRestrict=POST"
steps:
- convertBodyTo:
type: java.lang.String
- setProperty:
name: ticket
simple: "${body}"
- to: "openai:chat-completion?model={{env:OPENAI_MODEL:gpt-4o-mini}}&baseUrl={{env:OPENAI_BASE_URL:https://api.openai.com/v1}}&temperature=0&systemMessage=Label the support ticket with exactly one word. billing for payments and invoices, technical for errors and outages, other for everything else."
- setHeader:
name: category
simple: "${body.trim().toLowerCase()}"
- setBody:
simple: "${exchangeProperty.ticket}"
- choice:
when:
- simple: "${header.category} == 'billing'"
steps:
- to: "kafka:tickets-billing?brokers={{env:KAFKA_BROKERS:localhost:9092}}"
- simple: "${header.category} == 'technical'"
steps:
- to: "kafka:tickets-technical?brokers={{env:KAFKA_BROKERS:localhost:9092}}"
otherwise:
steps:
- setHeader:
name: category
constant: other
- to: "kafka:tickets-general?brokers={{env:KAFKA_BROKERS:localhost:9092}}"
- setHeader:
name: CamelHttpResponseCode
constant: 202
- setHeader:
name: Content-Type
constant: application/json
- setBody:
simple: '{"category": "${header.category}"}'The test sends three tickets: an invoice question goes to tickets-billing, an error report to
tickets-technical, and a prompt-injection attempt (the model answers with an invented label)
to tickets-general.
docker run --rm --network host -e KAFKA_BROKERS=localhost:9092 -e OPENAI_API_KEY \
-v "$PWD:/routes:ro" pmsudhi/cameleer run /routes/llm-classify-route.camel.yamlTo classify events from Kafka instead of HTTP, replace the from URI with
kafka:support-tickets?brokers=...&groupId=triage, as in
ai-ticket-triage.camel.yaml.
Summarization pipeline
Long documents do not fit in one prompt, and one huge prompt is slow and expensive. The route
splits each document into paragraphs, summarizes each paragraph, and asks the model to combine
the partial summaries. The split's GroupedBodyAggregationStrategy collects the summaries into
a list, and a list body is sent to the openai component as one user message per item.
- route:
id: summarize-documents
from:
uri: "file:{{env:INBOX_DIR:inbox}}?include=.*\\.txt&move=.done&moveFailed=.failed"
steps:
- convertBodyTo:
type: java.lang.String
- split:
tokenize: "\n\n"
aggregationStrategy: "#class:org.apache.camel.processor.aggregate.GroupedBodyAggregationStrategy"
steps:
- to: "openai:chat-completion?model={{env:OPENAI_MODEL:gpt-4o-mini}}&baseUrl={{env:OPENAI_BASE_URL:https://api.openai.com/v1}}&temperature=0&systemMessage=Summarize this paragraph in one sentence."
- to: "openai:chat-completion?model={{env:OPENAI_MODEL:gpt-4o-mini}}&baseUrl={{env:OPENAI_BASE_URL:https://api.openai.com/v1}}&temperature=0&systemMessage=The messages are summaries of consecutive paragraphs of one document. Combine them into a summary of at most five sentences."
- to: "file:{{env:OUTBOX_DIR:outbox}}?fileName=${file:onlyname.noext}.summary.txt"
- log: "summarized ${file:name} (${header.CamelOpenAITotalTokens} tokens for the last call)"Drop report.txt into the inbox and report.summary.txt appears in the outbox. The test checks
that a three-paragraph document makes four model calls (three paragraphs, one combine). Add
parallelProcessing: true to the split to summarize paragraphs concurrently, and a
throttle to stay under the provider's rate limit.
RAG ingestion: chunk, embed and store
Retrieval-augmented generation needs an index of your documents. This route watches a folder,
splits each document into paragraph chunks, embeds each chunk with openai:embeddings, and
indexes {source, chunk, text, embedding} in Elasticsearch or OpenSearch under the id
<file>-<chunk>, so re-dropping a changed file overwrites its chunks.
- route:
id: rag-ingest
from:
uri: "file:{{env:INBOX_DIR:docs}}?include=.*\\.(md|txt)&move=.indexed&moveFailed=.failed"
steps:
- convertBodyTo:
type: java.lang.String
- setHeader:
name: docId
simple: "${file:onlyname.noext}"
- split:
tokenize: "\n\n"
steps:
- filter:
simple: "${body.trim()} != ''"
steps:
- setProperty:
name: chunk
simple: "${body.trim()}"
- to: "openai:embeddings?model={{env:EMBEDDING_MODEL:text-embedding-3-small}}&baseUrl={{env:OPENAI_BASE_URL:https://api.openai.com/v1}}"
- setHeader:
name: indexId
simple: "${header.docId}-${exchangeProperty.CamelSplitIndex}"
- setBody:
jq: '{source: header("CamelFileName"), chunk: property("CamelSplitIndex"), text: property("chunk"), embedding: .}'
- to: "elasticsearch://rag?hostAddresses={{env:ES_HOSTS:localhost:9200}}&indexName={{env:ES_INDEX:docs}}&operation=Index"
- log: "indexed ${file:name}"The jq step builds the document: . is the embedding vector returned by the model, and
header() and property() read the file name and the chunk text. Empty chunks are skipped
by the filter. The same shape works for other sources (aws2-s3, google-drive, sftp, mail)
and other stores: PostgreSQL with pgvector through the sql component,
or a vector database's REST API through http. Create the index with a
dense_vector (Elasticsearch) or knn_vector (OpenSearch) mapping before you ingest.
Expose a tool to an agent over REST
Agents call tools; a tool is a route behind an HTTP endpoint. This refund tool shows the four things every side-effecting tool needs:
- Validated arguments.
json-validatorchecks the call against schemas/refund-request.json, the same schema you give the agent in its tool definition. A bad call gets400with every error, so the agent can correct itself. - Idempotency. Agents retry.
idempotentConsumeron therequestIdargument makes sure a repeated call does not refund twice; the repeat gets{"status": "duplicate"}. - Retries. The payments call is retried three times with exponential back-off on HTTP
errors and I/O errors, then answered with
502. - An audit log. A wire tap appends every accepted call to
tool-calls.jsonlwithout slowing the call down.
- rest:
path: /tools
post:
- path: /refund
to: direct:refund-tool
- routeConfiguration:
onException:
- onException:
exception:
- org.apache.camel.component.jsonvalidator.JsonValidationException
handled:
constant: true
steps:
- setHeader:
name: CamelHttpResponseCode
constant: 400
- setBody:
simple: "invalid tool arguments: ${exception.message}"
- onException:
exception:
- org.apache.camel.http.base.HttpOperationFailedException
- java.io.IOException
redeliveryPolicy:
maximumRedeliveries: 3
redeliveryDelay: 200
useExponentialBackOff: true
handled:
constant: true
steps:
- setHeader:
name: CamelHttpResponseCode
constant: 502
- setBody:
simple: "payments API unavailable, the refund was not confirmed; retry later with the same requestId"
- route:
id: refund-tool
from:
uri: direct:refund-tool
steps:
- convertBodyTo:
type: java.lang.String
- to: "json-validator:file:{{env:SCHEMA_DIR:schemas}}/refund-request.json"
- unmarshal:
json: {}
- setHeader:
name: requestId
simple: "${body[requestId]}"
- wireTap:
uri: direct:audit
- idempotentConsumer:
header: requestId
steps:
- removeHeaders:
pattern: "CamelHttp*"
- marshal:
json: {}
- enrich:
uri: "{{env:PAYMENTS_URL:http://localhost:9000}}/refunds?httpMethod=POST"
- setBody:
simple: '{"status": "refunded", "requestId": "${header.requestId}", "payment": ${body}}'
- filter:
simple: "${exchangeProperty.CamelDuplicateMessage} == true"
steps:
- setBody:
simple: '{"status": "duplicate", "requestId": "${header.requestId}"}'
- setHeader:
name: Content-Type
constant: application/json
- route:
id: audit-tool-calls
from:
uri: direct:audit
steps:
- marshal:
json: {}
- setBody:
simple: "${body}\n"
- to: "file:{{env:AUDIT_DIR:audit}}?fileName=tool-calls.jsonl&fileExist=Append"Two details in the payments call:
removeHeaders: CamelHttp*comes first, because the incoming request'sCamelHttpUriandCamelHttpPathheaders would otherwise change where thehttpproducer sends the request, as in Apache Camel.- The call is an
enrich, not ato.enrichsends a copy of the message, so a retry sends the original request again. Withto, a retry after an HTTP error would send the error response instead, because Cameleer'shttpproducer puts the response into the message before it fails (Apache Camel leaves the message unchanged). The reply of a successful call replaces the body, as withto.
The test makes the payments mock fail the first call with 503, and checks the retry, the
duplicate, the 400 for a call without orderId and with an amount over the schema's maximum,
and the two audit lines.
For a tool that spans several systems (refund, return label, email) in one call, with a saga that rolls back half-done work, see AI agent tool calls: direct vs through Cameleer.
For one endpoint per tool without one route per tool, use toD: direct:tool-${header.name} on
a path parameter, with a fixed set of direct: routes behind it. A packaged MCP server is on the
roadmap.
Human-in-the-loop approval
Some actions should not run on the model's word alone. Here refunds up to APPROVAL_LIMIT run at
once. Larger ones are written to a pending folder, and a Slack message asks a person to
approve. The approval endpoint takes the parked action with pollEnrich (deleting the file) and
executes it, so an action runs at most once even if the link is clicked twice.
- rest:
post:
- path: /actions
to: direct:propose
- path: /approvals/{id}
to: direct:approve
- route:
id: propose-action
from:
uri: direct:propose
steps:
- convertBodyTo:
type: java.lang.String
- setProperty:
name: action
simple: "${body}"
- unmarshal:
json: {}
- setHeader:
name: actionId
simple: "${body[actionId]}"
- choice:
when:
- simple: "${body[amount]} <= {{env:APPROVAL_LIMIT:100}}"
steps:
- setBody:
simple: "${exchangeProperty.action}"
- to: direct:execute
- setBody:
simple: '{"status": "executed", "actionId": "${header.actionId}"}'
otherwise:
steps:
- setBody:
simple: "${exchangeProperty.action}"
- to: "file:{{env:PENDING_DIR:pending}}?fileName=${header.actionId}.json"
- setBody:
simple: "Agent wants to refund ${jsonpath($.amount)} on order ${jsonpath($.orderId)}. Approve: {{env:PUBLIC_URL:http://localhost:8080}}/approvals/${header.actionId}"
- to: "slack:#approvals?webhookUrl={{env:SLACK_WEBHOOK_URL}}"
- setHeader:
name: CamelHttpResponseCode
constant: 202
- setBody:
simple: '{"status": "pending_approval", "actionId": "${header.actionId}"}'
- route:
id: approve-action
from:
uri: direct:approve
steps:
- setProperty:
name: actionId
simple: "${header.id}"
- pollEnrich:
uri: "file:{{env:PENDING_DIR:pending}}?fileName=${header.id}.json&delete=true"
timeout: 1000
- choice:
when:
- simple: "${body} == null"
steps:
- setHeader:
name: CamelHttpResponseCode
constant: 404
- setBody:
simple: "no pending action ${exchangeProperty.actionId}"
otherwise:
steps:
- to: direct:execute
- setBody:
simple: '{"status": "executed", "actionId": "${exchangeProperty.actionId}"}'
- route:
id: execute-action
from:
uri: direct:execute
steps:
- removeHeaders:
pattern: "CamelHttp*"
- to: "{{env:PAYMENTS_URL:http://localhost:9000}}/refunds?httpMethod=POST"The test proposes a 40 refund (executed at once) and a 350 refund (parked, Slack message with
the approval link), approves it, checks that the payments API got it, and checks that a second
approval answers 404. Protect /approvals with your SSO or API gateway: anyone who can call it
can approve. To approve from Slack buttons instead of links, point a Slack interactivity URL at
a platform-http route that parses the payload.
Guard LLM output with a JSON schema
When a model's answer feeds another system, check it first. The route asks for JSON, validates
it with json-validator against
schemas/invoice.json, and on failure asks once
more, passing the validation errors back to the model. A second failure becomes 422, so bad
data never reaches the caller.
- route:
id: extract-invoice
from:
uri: "platform-http:/extract?httpMethodRestrict=POST"
steps:
- convertBodyTo:
type: java.lang.String
- setProperty:
name: document
simple: "${body}"
- doTry:
steps:
- to: direct:ask-for-json
doCatch:
- exception:
- org.apache.camel.ValidationException
steps:
- setBody:
simple: "Your previous answer was rejected. ${exception.message}. Reply with corrected JSON only. Document follows. ${exchangeProperty.document}"
- doTry:
steps:
- to: direct:ask-for-json
doCatch:
- exception:
- org.apache.camel.ValidationException
steps:
- setHeader:
name: CamelHttpResponseCode
constant: 422
- setBody:
simple: "the model did not return a valid invoice: ${exception.message}"
- setHeader:
name: Content-Type
constant: application/json
- route:
id: ask-for-json
from:
uri: direct:ask-for-json
steps:
- to: "openai:chat-completion?model={{env:OPENAI_MODEL:gpt-4o-mini}}&baseUrl={{env:OPENAI_BASE_URL:https://api.openai.com/v1}}&temperature=0&systemMessage=Extract the invoice as a JSON object with the fields vendor (string), total (number) and currency (ISO 4217 code). Reply with the JSON object only."
- to: "json-validator:file:{{env:SCHEMA_DIR:schemas}}/invoice.json"The outer doTry catches the first ValidationException (an answer that is not JSON at all is
a JsonValidationException, a subkind of it) and asks again with the errors; the inner doTry
turns a second failure into 422. Both attempts call the same direct:ask-for-json route. The
test covers all three outcomes: valid at once, repaired on the second call, and rejected with
422.
Rate limits and a circuit breaker around a model API
Model APIs have rate limits and outages. The throttle keeps calls under five per second
(others wait, instead of getting 429 from the provider). The circuitBreaker counts failures;
when half of the last ten calls failed, it stops calling the API for 30 seconds and answers
from onFallback with 503 and Retry-After.
- route:
id: ask-model
from:
uri: "platform-http:/ask?httpMethodRestrict=POST"
steps:
- convertBodyTo:
type: java.lang.String
- throttle:
constant: 5
timePeriodMillis: 1000
- circuitBreaker:
resilience4jConfiguration:
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 30
timeoutEnabled: true
timeoutDuration: 60000
steps:
- to: "openai:chat-completion?model={{env:OPENAI_MODEL:gpt-4o-mini}}&baseUrl={{env:OPENAI_BASE_URL:https://api.openai.com/v1}}&maxTokens=300&systemMessage=Answer in at most three sentences."
onFallback:
steps:
- setHeader:
name: CamelHttpResponseCode
constant: 503
- setHeader:
name: Retry-After
constant: 30
- setBody:
constant: "The assistant is unavailable right now. Please retry in 30 seconds."The test runs it twice: against the model mock (six calls take at least a second because of
the throttle), and against an API that is down (every call gets the fallback 503 at once).
For a limit per tenant or per API key, add correlationExpression: {header: tenant} to the
throttle.
Multi-model fallback
Use a hosted model first and a local one when it fails. A failover load balancer tries the
primary endpoint and moves to the fallback on any exception: a connection error, a timeout, or
an HTTP error such as 429 or 401 (the openai component fails with
HttpOperationFailedException on API errors).
- route:
id: chat-with-fallback
from:
uri: "platform-http:/chat?httpMethodRestrict=POST"
steps:
- convertBodyTo:
type: java.lang.String
- loadBalance:
failover:
maximumFailoverAttempts: 1
steps:
- to: "openai:chat-completion?model={{env:PRIMARY_MODEL:gpt-4o-mini}}&baseUrl={{env:PRIMARY_BASE_URL:https://api.openai.com/v1}}&timeout=30s"
- to: "openai:chat-completion?model={{env:FALLBACK_MODEL:llama3.1}}&baseUrl={{env:FALLBACK_BASE_URL:http://localhost:11434/v1}}&timeout=60s"
- setHeader:
name: X-Answered-By
simple: "${header.CamelOpenAIResponseModel}"The X-Answered-By header comes from CamelOpenAIResponseModel, the model name the API
reported. The test checks both paths: primary down (the local model answers) and primary up
(the fallback is never called). To prefer a cheaper model and fall back to a stronger one, swap
the two endpoints; to spread load instead, use roundRobin (see
load balancer).
Patterns that combine well
- Put a
filterand anidempotentConsumerin front of model calls: spam and duplicates cost tokens too. wireTapevery prompt and answer to Kafka or a file for an audit trail and evaluation data.- Use
enrichto add customer data from SQL or a CRM to the prompt. - Run the routes as a sidecar that starts in milliseconds next to your agent (Docker image
pmsudhi/cameleer) or embed them in a Rust agent withcameleer_main::new_main().
Common questions
How do I call an LLM from a Cameleer route?
Send the prompt as the body to openai:chat-completion?model=...; the body becomes the
model's answer and the token counts are in CamelOpenAI*Tokens headers. Set baseUrl for
Ollama, vLLM or another OpenAI-compatible server, or use aws-bedrock, azure-openai or
google-vertexai.
Can an AI agent call Cameleer routes as tools?
Yes. Expose each tool as a REST endpoint (rest or platform-http), validate the arguments
with json-validator, and add idempotency and retries, as in the
tool pattern. An MCP server component is planned.
How do I stop an agent from executing the same action twice?
Have the agent send a unique request id with every tool call and wrap the side effect in
idempotentConsumer on that id. Use the JDBC or Redis idempotent repository to keep the ids
across restarts and instances.
How do I test routes that call an LLM without paying for tokens?
Point baseUrl at a mock. cameleer_saas::testing::OpenAiMock is an OpenAI-compatible mock
for chat and embeddings, and the tests of this guide use it.
Does Cameleer run models or store vectors?
No. It calls the models and moves embeddings into the stores you already run (Elasticsearch, OpenSearch, PostgreSQL with pgvector, or any store with an HTTP API).
Related
- AI agent tool calls: direct vs through Cameleer: a multi-step tool with a saga, compared with letting the model call each API
- AI components, openai component
- EIPs, error handling, edge cookbook
- Roadmap: agent tool server and agent loop
Found a problem?
If something on this page doesn't match what Cameleer does, or you hit a bug while building
with it, please help fix it, whether you are a person or a coding agent:
open an issue that describes what you saw,
then fork the repository, fix it, add or update a test, run cargo test --workspace, and open
a pull request. CONTRIBUTING.md
explains the ground rules.