Skip to contents

This 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|net

On 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 can ask or deny it. Built-in tool metadata stays authoritative.

Fine-grained control is available through settings$tools:

settings$tools <- list(
  overrides    = list(RunAnalysis = "ask"),      # per-tool: allow | ask | deny
  capabilities = list(exec = "ask", net = "deny")# per-capability policy
)

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.