AI Tool component
The
ai-toolcomponent turns a route into a tool an AI agent can call. Every call goes through a reliability envelope: the arguments are checked against a JSON Schema, a retried call runs once, destructive calls wait for approval, a slow call is cancelled, and each call is written to a tamper-evident audit log.
Scheme: ai-tool: · Crate: cameleer-ai · Cargo feature: ai ·
Consumer: yes · Producer: yes (Cameleer extension) · Apache Camel: AI Tool
When to use it
- Give an agent an action it can take in your systems (issue a refund, create a ticket, book a slot) and enforce the rules in the route, not in the prompt.
- Make an existing integration route callable by a model, with the same error handling, retries and connectors it already has.
- Keep a record of what an agent did, for whom, and with which approval.
URI format
ai-tool:name[?options]name is the tool's name as the model sees it. A route that consumes from ai-tool:name
publishes the tool when it starts and withdraws it when it stops; two routes can't publish the
same name. A producer (to: ai-tool:name) calls the tool through the envelope, which is how
routes and tests call tools.
Example
A refund tool. The agent sends {"orderId": "EU-4411", "amount": 25.5}. The arguments arrive
as headers, and the route's body becomes the result.
- route:
from:
uri: ai-tool:refund
parameters:
description: Refund part or all of an order
parameter.orderId: string
parameter.orderId.description: The order to refund
parameter.orderId.required: true
parameter.amount: number
parameter.amount.required: true
destructiveHint: true # waits for approval (approval=auto)
approvalEndpoint: direct:ask-a-human # who is asked
compensation: direct:reverse-refund # undo it if the conversation fails later
timeout: 10s
steps:
- toD: "https://payments.internal.example.com/orders/${header.orderId}/refund?amount=${header.amount}"
- setBody:
simple: "Refunded ${header.amount} on order ${header.orderId}"
- route:
from:
uri: direct:ask-a-human
steps:
- to: log:approvals # or slack, e-mail, a ticket queueThe same tool in the Rust DSL, called with a retry:
use cameleer_ai::{AiRegistry, ToolCall};
use cameleer_core::prelude::*;
let ctx = CamelContext::new();
cameleer_ai::register(&ctx);
ctx.add_routes(|b| {
b.from("ai-tool:weather?description=Weather for a city¶meter.city=string¶meter.city.required=true&readOnlyHint=true")
.set_body(simple("${header.city}: 21 degrees"));
})?;
ctx.start()?;
let tools = AiRegistry::of(&ctx)?;
let result = tools.call(ToolCall::new("weather", cameleer_core::json::parse(r#"{"city":"Oslo"}"#)?).with_call_id("call-1"));
assert_eq!(result.text, "Oslo: 21 degrees");Options
Definition (Camel names)
| option | default | description |
|---|---|---|
description | the tool's name | What the tool does; the model reads it to decide when to call the tool. Without one the name is used, as in Camel |
title | A human-readable name | |
tags | Comma-separated tags; an agent or server can offer only the tools with given tags | |
parameter.NAME | string | Declares an argument and its type: string, integer, number, boolean, object, array |
parameter.NAME.description | The argument's description | |
parameter.NAME.required | false | Whether the argument is required |
parameter.NAME.enum | Comma-separated allowed values | |
argSchema | A full JSON Schema for the arguments, inline or file:PATH, instead of parameter.* (never fetched over the network) | |
outputSchema | A JSON Schema for the structured result, inline or file:PATH | |
outputParameter.NAME | Declares a field of the result, like parameter.NAME | |
argumentsAsBody | false | Also pass the arguments as the body, as a JSON string |
readOnlyHint | The tool changes nothing | |
destructiveHint | The tool may destroy or overwrite something | |
idempotentHint | Calling it twice with the same arguments has the same effect as once | |
openWorldHint | The tool reaches outside systems | |
searchable | true | Whether tool search may return it |
returnDirect | false | The result goes straight to the user, not back to the model |
authorizationPolicy | A bean (#name) implementing ToolAuthorizationPolicy that decides per call and principal |
Reliability envelope (Cameleer extensions)
In Camel the hints are advisory. Here they set the defaults of an envelope that every caller goes through.
| option | default | description |
|---|---|---|
idempotency | auto | auto deduplicates tools that are neither readOnlyHint nor idempotentHint; true or false forces it |
idempotencyWindow | 1h | How long a call's result is remembered |
idempotentRepository | in memory | A bean (#name, an IdempotentRepository) that records keys across processes |
approval | auto | auto asks for approval when destructiveHint=true; always; never; or a Simple predicate over the arguments, such as ${header.amount} > 500 |
approvalEndpoint | An endpoint told about each call that waits (arguments as headers, CamelAiToolApprovalHandle) | |
approvalTimeout | 1h | How long a call waits for approval |
timeout | 20s | How long a caller waits; then the call's route is cancelled. 0 runs on the caller's thread with no limit |
rateLimit | Calls allowed per principal in rateLimitPeriod | |
rateLimitPeriod | 1m | The rate limit's period |
circuitBreakerThreshold | 0 (off) | Consecutive failures that open the breaker |
circuitBreakerDelay | 30s | How long the breaker stays open before one trial call |
circuitBreakerFallback | The text the model gets while the breaker is open | |
compensation | An endpoint (usually direct:) that undoes the call; see Compensation | |
authorize | A Simple predicate over the arguments and the caller; a call it doesn't match is refused. See Guardrails | |
redact | Kinds of data replaced in results: secret, email, creditCard, iban, ssn, ipv4, phone, all, or regex:NAME=PATTERN, comma separated. See Guardrails | |
cacheTtl | 0 (off) | Reuse a successful result for the same arguments and principal for this long |
audit | true | Write this tool's calls to the audit log |
version | 1 | The definition's version, for pinning |
pin | true | Refuse a changed definition that keeps its version |
throwOnError | false | Producer: raise ToolCallException instead of returning an error result |
Message headers
| header | direction | description |
|---|---|---|
| each argument, by name | in (consumer) | The call's arguments; camel* and undeclared arguments are dropped |
CamelAiToolCallId | in | The model's or protocol's id for the call |
CamelAiToolConversationId | in | The conversation or saga the call belongs to |
CamelAiToolName | in | The tool's name |
CamelAiToolPrincipal | in | Who the call is made for |
CamelAiToolApprovedBy | in (producer) | Marks the call as approved by this approver |
CamelAiToolStatus | out (producer) | completed, error, approval-required or denied |
CamelAiToolIsError | out (producer); in (tool route) | Producer: true when the call failed or was refused. In a tool route: set it to true to refuse the call yourself; the body becomes the error text the model reads (MCP isError), shown word for word, unlike an exception, whose cause is hidden from MCP clients. Use it for business refusals the model should act on, such as "order 1007 has shipped, so its address can no longer be changed" |
CamelAiToolDuplicate | out (producer) | true when the recorded result of an earlier call was returned |
CamelAiToolApprovalHandle | out (producer) | The handle of a call that waits for approval |
CamelAiToolReturnDirect | out (producer) | The tool's returnDirect flag |
CamelAiToolStructuredContent | out (producer) | The structured result (an object or array) |
CamelAiToolContent | out (tool route) | Set by the route: MCP content blocks for the result (images, audio, embedded resources) |
CamelMcpSecurityPrincipal, CamelMcpSecurityPrincipalName, CamelMcpSecurityPrincipalRoles, CamelMcpSecurityPrincipalScopes, CamelMcpSecurityMethod | in (over MCP) | The authenticated caller: its name, its scopes as a list (Roles) and as one space-separated string (Scopes), and how it authenticated |
CamelAiToolProgress, CamelAiToolProgressTotal | in (operation=progress) | The progress so far and the total, if known |
CamelAiToolLogLevel, CamelAiToolLogger | in (operation=log) | The level (default info) and the logger name |
CamelAiToolElicitSchema | in (operation=elicit) | The JSON Schema of the answer, when the body is the message text |
CamelAiToolElicitAction | out (operation=elicit) | accept, decline or cancel |
CamelAiToolSampleMaxTokens | in (operation=sample) | The maximum tokens of the answer (default 500) |
CamelAiToolSampleModel | out (operation=sample) | The model that answered |
Behavior
A call, step by step
Kill switch. A tool named in
camel.ai.disabled-tools, or disabled withAiRegistry::disable, is no longer listed and every call is refused.Authorization. The
authorizationPolicybean may refuse the call; its reason goes back to the model.Arguments. Arguments whose name starts with
camel(any case) are dropped, as in Camel, and so are arguments the schema doesn't declare, unless it allows them (additionalProperties). What is left is validated against the full JSON Schema (2020-12, see json-validator). Camel only checks required fields. A failure returns an error that names each field and shows valid arguments:textInvalid arguments for tool weather: - $.city: is missing but it is required - $.unit: does not have a value in the enumeration [celsius, fahrenheit] Example of valid arguments: {"city":"text"}
authorize. The predicate sees the arguments as headers and the caller; a call it doesn't match is refused (see Guardrails).- Result cache. With
cacheTtl, a result recorded for the same arguments and principal is returned and the route doesn't run. - Rate limit, per principal.
- Circuit breaker. While it is open, calls get the fallback text and the route doesn't run.
- Approval. A call that needs approval doesn't run. It is parked with a handle and the
result says so (
CamelAiToolStatus=approval-required). An approver resolves it withAiRegistry::approve(handle, approver), which runs it and returns its result, orAiRegistry::deny(handle, approver, reason), which runs nothing. The approved call's route sees the approver inCamelAiToolApprovedBy. With anapprovalEndpoint(approval out of band, by a person who decides later), the caller learns the outcome by making the same call again: while it waits it gets the same handle ("still waiting"), after the decision the result (or the rejection with its reason), without a second request to the approver. Over MCP, a tool without an approval endpoint asks the client's user instead (see MCP server: approvals). - Idempotency. The key is the
idempotency_keyargument if the schema declares one, else the call id, else a digest of the principal, the conversation (CamelAiToolConversationId; over MCP the session, orio.cameleer/conversationId) and the arguments, so a retry in the same conversation is caught and two conversations that ask for the same thing both run. A repeated call returns the recorded result withCamelAiToolDuplicate=true. A repeat that arrives while the first is running waits for it. A failed call is not recorded, so a retry runs it. - Compensation. The undo step is registered, see Compensation.
- The route runs on a new exchange that keeps only the caller's properties and variables
(
CamelVariable.*), as in Camel. Headers and the body don't leak into the tool. - Timeout. When the caller stops waiting, the call is cancelled: the route stops before its next step (a step that is already running is not interrupted), and the exchange is released. In Camel the call keeps running.
- Result. An object or array body is the structured result, with its JSON as the text. An
empty body is
No result, and any other body is its text. With anoutputSchema, a result that doesn't match it is an error. - Redaction. With
redact, matching values in the result are replaced. - Audit. One record per call, see Audit log.
Guardrails
Three options put rules on a tool that a prompt can't talk its way around.
authorize is a Simple predicate. It is evaluated after the
arguments are validated, on an exchange whose headers are the arguments, the call's own
headers (over MCP, CamelMcpSecurityPrincipal, CamelMcpSecurityPrincipalRoles and the
others), CamelAiToolPrincipal and CamelAiToolName. If it doesn't match, the route doesn't
run and the model gets Not authorized to call refund_small with these arguments. The audit
record has the decision denied and the rule. authorizationPolicy (a bean) is checked
before it, for rules that need code.
Inside an endpoint URI, & separates options, so write && as %26%26 (and + as %2B,
because + decodes to a space). The same goes for YAML parameters:: they are joined into the
URI, and a value that contains ${ or {{ is copied as it is, not URL-encoded, so a
predicate's && would cut it in two. RAW(...) also works when the predicate has no ).
- route:
from:
uri: ai-tool:refund_small
parameters:
description: Refund up to 100 on an order
parameter.orderId: string
parameter.orderId.required: true
parameter.amount: number
parameter.amount.required: true
authorize: "${header.amount} <= 100 %26%26 ${header.CamelAiToolPrincipal} == 'support'"
steps:
- setBody:
simple: "Refunded ${header.amount} on order ${header.orderId}"
- route:
from:
uri: ai-tool:customer
parameters:
description: A customer's record
parameter.id: string
parameter.id.required: true
readOnlyHint: true
redact: email,phone,secret # or all, or regex:ticket=TCK-[0-9]+
cacheTtl: 5m
steps:
- to: direct:load-customer
- route:
from:
uri: direct:load-customer
steps:
- setBody:
simple: '{"id": "${header.id}", "name": "Ann", "email": "ann@example.com"}'redact replaces personal data and secrets in a completed result with
[REDACTED:kind], in the text, the structured result and the content blocks. It is applied
before the result reaches the model, the MCP client or the audit log (the audit record keeps
the digest of the redacted result and notes redacted N value(s)). The kinds:
| kind | matches |
|---|---|
secret | API keys and tokens: sk-..., sk-ant-..., AWS AKIA..., GitHub ghp_... and similar, Slack xox?-..., JWTs, PEM private key headers |
email | E-mail addresses |
creditCard | 13 to 19 digits, with spaces or dashes, that pass the Luhn check |
iban | IBANs: two letters, two digits, 11 to 30 letters or digits |
ssn | US social security numbers, 123-45-6789 |
ipv4 | IPv4 addresses |
phone | Phone numbers: an optional +, then digits with spaces, dashes, dots or parentheses between them, 9 characters or more |
all | Every kind above |
regex:NAME=PATTERN | Your own pattern, replaced with [REDACTED:NAME] |
Custom patterns run first, then the built-in kinds in the order of the table, so a card number is not also taken for a phone number. A pattern can't contain a comma, because commas separate the kinds. An unknown kind or an invalid pattern fails when the route starts.
cacheTtl reuses a successful result for the same arguments and principal (a SHA-256 of
both) for that long. Errors are not cached. A cached call doesn't run the route, the rate limit
or the approval, and its audit record has the decision cached. Use it for read-only tools
whose answer doesn't change by the second; for deduplicating calls with side effects, use
idempotency.
Talking to the client: progress, logs, elicitation and sampling
While a tool route runs for an MCP client, it can send progress and log messages to the
client, ask the client's user a question (elicitation) or ask the client's model for a
completion (sampling). A step to: ai-tool:current?operation=... does it. The name in the URI
is not used; the step finds the running call from the exchange.
- route:
from:
uri: ai-tool:import_contacts
parameters:
description: Import a contact list after the user confirms the target list
parameter.file: string
parameter.file.required: true
steps:
- setHeader:
name: CamelAiToolElicitSchema
constant: '{"type": "object", "properties": {"list": {"type": "string"}}, "required": ["list"]}'
- setBody:
constant: Which mailing list should the contacts go to?
- to: ai-tool:current?operation=elicit
- choice:
when:
- simple: "${header.CamelAiToolElicitAction} != 'accept'"
steps:
- setBody:
constant: Import cancelled by the user
- stop: {}
- setProperty:
name: list
simple: "${body[list]}"
- setHeader:
name: CamelAiToolProgress
constant: 1
- setHeader:
name: CamelAiToolProgressTotal
constant: 2
- setBody:
constant: Reading the file
- to: ai-tool:current?operation=progress
- setHeader:
name: CamelAiToolLogLevel
constant: info
- setBody:
simple: "Importing into ${exchangeProperty.list}"
- to: ai-tool:current?operation=log
- setBody:
simple: "Imported ${header.file} into ${exchangeProperty.list}"| operation | input | what the client gets | after the step |
|---|---|---|---|
progress | CamelAiToolProgress (required), CamelAiToolProgressTotal, the body as the message | notifications/progress, only when the request carried a progressToken | nothing changes |
log | CamelAiToolLogLevel (default info), CamelAiToolLogger, the body as data | notifications/message, only when the client asked for logs at that level or below | nothing changes |
elicit | the body as the message and CamelAiToolElicitSchema, or the body as a whole {message, requestedSchema} object | elicitation/create | the body is the answer's content; CamelAiToolElicitAction is accept, decline or cancel |
sample | the body as the user's message and CamelAiToolSampleMaxTokens (default 500), or the body as a whole sampling/createMessage params object | sampling/createMessage | the body is the text of the answer; CamelAiToolSampleModel is the model that answered |
Log levels are MCP's: debug, info, notice, warning, error, critical, alert,
emergency. In 2026-07-28 the client sets the minimum level in the request's _meta
(io.modelcontextprotocol/logLevel); in legacy sessions it calls logging/setLevel. Without
either, no log messages are sent. Notifications go on the request's event stream over HTTP and
on stdout over stdio.
How a question reaches the client depends on the era:
- 2025-11-25 and earlier. The server sends
elicitation/createorsampling/createMessageto the client during the call, on the response stream (HTTP) or on stdout (stdio), and the step waits up to 10 minutes for the answer. - 2026-07-28. The server sends no requests to the client. The call ends with
resultType: input_required, the questions ininputRequestsand a sealedrequestState. The client gets the answers and calls again withinputResponses. The route runs again from the start, and this time each step gets its answer, in order. Keep the steps before a question free of side effects, or make them idempotent.
A client that didn't declare the elicitation or sampling capability can't be asked: the
step fails with the error kind org.apache.camel.component.ai.InteractionUnavailableException
(the client did not declare the sampling capability), and so does a legacy client that
doesn't answer. Handle it with doTry or onException to fall back, for example to a default
value. Unhandled, the call fails and the client gets the reason; it is not hidden like other
route errors (see camel.server.mcp-expose-errors in the
MCP server configuration).
Outside an MCP call (the producer, the agent step, tags on a model component), progress and
log do nothing, and elicit and sample fail with InteractionUnavailableException.
Compensation
compensation=direct:undo-x registers the undo step before the call runs, under the
call's conversation id (CamelAiToolConversationId; calls without one register nothing). If
the conversation fails later, AiRegistry::compensate(conversation) runs every registered
undo step, newest first. Each undo step receives the original arguments as headers, the call's
result as the body and CamelAiToolCompensating=true.
AiRegistry::complete(conversation) ends a conversation and drops its undo steps.
Audit log
Each call appends a record: time, tool, principal, call and conversation ids, a SHA-256 digest
of the arguments and of the result (never the data itself), the decision (allowed,
invalid, approval-required, approved, rejected, rate-limited, circuit-open,
disabled, denied, cached, compensate), the approver, the duplicate flag, the outcome and the
duration. Records are hash-chained: each one holds the SHA-256 of the previous record plus its
own content, so an edited, removed or reordered record fails
cameleer_ai::audit::verify_file.
| property | default | description |
|---|---|---|
camel.ai.audit.enabled | true | Keep audit records |
camel.ai.audit.file | Also append them to this JSON lines file; an existing file is verified and continued | |
camel.ai.audit.maxRecords | 1000 | Records kept in memory (AiRegistry::audit().records()) |
Pinning
A tool's definition hash covers its name, title, description, schemas and hints. If a route
changes a tool's definition but keeps its version, the route fails to start:
Tool transfer changed (definition hash 3f2a9c1d04be is now 9b71e0aa52c3) but its version is still 1: set a new version= to publish the changeWithin one process the hashes are kept in memory. With camel.ai.manifest=tools.json they
are kept in that file, so the check also covers restarts and deployments. A tool's description
or schema can't change under a client that already trusts it.
Kill switch
camel.ai.disabled-tools=a,b withdraws tools at startup. AiRegistry::disable(name) and
enable(name) do it at runtime. Either way the tool disappears from every list, its calls are
refused, and AiRegistry::on_change listeners are told, so MCP clients get
notifications/tools/list_changed.
An operator does the same from the command line, against an integration started with
cameleer run (it finds the process by name or pid, like cameleer stop):
$ cameleer tool list orders
NAME TAGS READ-ONLY DESTRUCTIVE VERSION STATUS
lookup orders yes 1 enabled
refund orders yes 2 enabled
Pending approvals: 1
$ cameleer tool disable refund orders
tool refund disabled
$ cameleer tool enable refund orders
tool refund enabledcameleer tool approvals lists the calls waiting for approval (handle, tool, principal, age,
reason). cameleer tool approve <handle> [--by NAME] runs one and prints its result, and
cameleer tool deny <handle> [--by NAME] [--reason TEXT] rejects it; both go to the audit log
with the approver (--by, default the user name). tool list and tool approvals take
--json. A name that no route publishes is refused, so a typo doesn't go unnoticed.
With the dev console (camel.server.dev-console-enabled=true), GET /q/dev/tools returns the
same list as JSON, and POST /q/dev/tools/{name}/disable and /enable flip the switch. Both
need the ai feature in the running binary; without it they say so.
Differences from Apache Camel
- The hints and policies are enforced, not advisory: deduplication, approval, timeouts that cancel, rate limits, circuit breakers, compensation, audit, pinning and the kill switch are Cameleer extensions.
- Arguments are validated against the full schema, not only the required fields.
- Results keep their structure: a map or list is JSON plus structured content, not
toString(). - The producer (calling a tool from a route) is a Cameleer extension.
- The MCP server serves these tools to MCP clients in both protocol eras (2026-07-28 and 2025-11-25), with the same envelope.
- A route can return images, audio or embedded resources by setting
CamelAiToolContentto a list of MCP content blocks. authorize,redact,cacheTtland the interaction operations (progress,log,elicit,sample) are Cameleer extensions.- Tools of remote MCP servers can be imported and go through the same envelope, see MCP client.
Related
- MCP server: serving these tools to MCP clients.
- MCP client and the mcp component: using the tools of remote MCP servers.
- ai-resource and ai-prompt: the other two things an agent can use.
- json-validator: the JSON Schema support used for arguments.
- Cameleer for AI agents: patterns built on these components.
- Build features: the
aifeature.
Found a problem?
If something on this page doesn't match what Cameleer does, please
open an issue, or fork the repository, fix
it, add a test, run cargo test --workspace and open a pull request.