Embedding codeagent as a backend (Contract v1)
Source:vignettes/backend-integration.Rmd
backend-integration.RmdThis vignette describes the stable public interface for embedding codeagent as the engine behind a host application (a Shiny app, an API service, another package). The host keeps full ownership of its UI, tools, skill content, data, and provider credentials; codeagent provides the agent loop, streaming, context compaction, permission gate, and skill loading.
Everything documented here is Backend Contract v1.
See Versioning for the stability promise. A
runnable reference lives at
system.file("examples/backend_integration_demo.R", package = "codeagent").
The boundary
| codeagent provides | The host owns |
|---|---|
| agent loop, multi-turn tool-calling | its UI |
provider abstraction (any ellmer Chat) |
its Chat (provider / model / key) |
| streaming + typed callbacks | rendering |
| context compaction | domain tools |
| central permission gate | skill content + data |
| skill loading | session storage (optional) |
1. Entry: a harness-only client
Pass your own ellmer::Chat and set
register_tools = FALSE so none of
codeagent’s coding tools (Bash / Write / Edit / Glob / Grep / git / web)
are attached — you get only the harness.
chat <- ellmer::chat_openai_compatible(
base_url = Sys.getenv("MY_BASE_URL"),
model = Sys.getenv("MY_MODEL"),
credentials = function() Sys.getenv("MY_API_KEY")
)
client <- codeagent::codeagent_client(
chat = chat,
register_tools = FALSE,
permission_mode = "default", # default | plan | accept_edits | bypass | ...
cwd = getwd()
)codeagent_client() returns a
CodeagentClient with $chat,
$settings, and $data_shield (NULL
unless enabled). For multi-user Shiny apps, create the client inside the
server session (for example via
codeagent_app(client_factory=)), never share one mutable
client across browser sessions.
2. Driving a turn + the callback contract
Use codeagent_stream() (blocking; pumps its own event
loop) or codeagent_stream_async() (returns a promise).
Rendering happens entirely through typed callbacks —
codeagent does not touch your UI.
codeagent::codeagent_stream(
client, user_input,
on_delta = function(text_chunk) { ... }, # incremental assistant text
on_thinking = function(chunk) { ... }, # reasoning / thinking content
on_tool_request = function(x) { ... }, # list(id, name, arguments, intent)
on_tool_result = function(x) { ... }, # list(id, name, display, value, is_error)
on_usage = function(usage) { ... }, # token usage
on_tick = function() { ... } # ~100 ms heartbeat (spinners)
)
# returns invisibly: list(text, usage, stop_reason)Callback payloads:
| Callback | Argument |
|---|---|
on_delta |
text_chunk (character) |
on_thinking |
thinking chunk |
on_tool_request |
list(id, name, arguments, intent) — fires
before the gate |
on_tool_result |
list(id, name, display, value, is_error) |
on_usage |
usage object |
on_tick |
none |
3. Rich tool results (text / table / image / error)
A tool’s value is the text the model sees. To also
render a rich artifact in your UI, return
tool_result(), which attaches a typed display card
delivered as on_tool_result$display.
my_tool <- ellmer::tool(
function(name) {
df <- summarise_something(name)
codeagent::tool_result(
sprintf("%d x %d summary", nrow(df), ncol(df)),
kind = "table",
payload = list(df = df),
title = "Summary"
)
},
name = "Summarise", description = "...", arguments = list(...)
)display$toolcard$kind +
display$toolcard$payload carry the structured artifact for
any host to render; codeagent’s own Shiny app additionally receives a
pre-rendered display$html /
display$right_output.
kind |
payload |
|---|---|
text |
list(text=) |
table |
list(df = <data.frame>) |
image |
list(images = list(list(mime=, b64=)), output=) |
code |
list(text=, lang=, filename=, output=) |
error |
list(message=, detail=) |
A non-Shiny host renders the artifact itself, e.g.:
on_tool_result <- function(x) {
if (identical(x$display$toolcard$kind, "table"))
my_render_table(x$display$toolcard$payload$df)
}4. Host tools + the permission gate
Register your tools the standard ellmer way, then declare each tool’s capability so the central gate governs it like a native tool.
chat$register_tool(my_tool)
codeagent::register_tool_meta("RunAnalysis", capability = "exec") # read|write|exec|netOn a harness-only client (register_tools = FALSE) the
gate is not installed automatically — install it once,
after attaching your tools, so they are governed and approvals route to
your ask_fn:
codeagent::install_permission_gate(
chat,
permission_mode = "default",
tool_meta = list(RunAnalysis = "exec"), # optional: declare capabilities here
ask_fn = function(name, input, id = NULL) { # `id` = tool-call id, matches on_tool_request
host_request_approval(id, name, input) # return a logical or a promise<logical>
}
)Important: an undeclared tool defaults to capability
"read"and is allowed without gating. If your tool executes code, writes files, or hits the network, declare it ("exec"/"write"/"net") so the gate canaskordenyit. Built-in tool metadata stays authoritative.
Fine-grained control is available through
settings$tools:
5. Skills
Point codeagent at your own <name>/SKILL.md
directories (scanned under cwd). Skill content is
yours; codeagent only loads and injects it.
codeagent::list_skills_meta(cwd = getwd())
codeagent::load_skill_prompt("my_skill", cwd = getwd())
codeagent::build_skill_hint(...)6. Provider
chat accepts any ellmer::Chat
(OpenAI-compatible, Databricks, Anthropic, Gemini, Bedrock, Azure, …) —
or any object exposing $stream_async(). The host owns and
supplies credentials; codeagent never reads them.
7. Versioning
Backend Contract v1 = the symbols below, with the
signatures documented above. Changes follow semantic versioning;
breaking changes bump the major and are announced in
NEWS.md. The guard test
test-backend-contract.R fails if this surface drifts.
-
codeagent_client(register_tools = FALSE)→{chat, settings, data_shield} -
codeagent_app(client_factory = )per-session client contract -
codeagent_stream()/codeagent_stream_async()+ callbacks +list(text, usage, stop_reason) agent_loop()tool_result()register_tool_meta()-
install_permission_gate()(+ask_fn(name, input, id = NULL)contract) -
DataShield/shield_describe()/shield_egress()/shield_regex()/shield_ingress()/shield_tool_policy()/shield_sandbox()/shield_reviewer() -
list_skills_meta()/load_skill_prompt()/build_skill_hint() -
CompactionController(compaction runs automatically; this is the injectable controller) switch_model()-
settings$toolspolicy:sets/capabilities/overrides