Build durable Ruby and Rails agents with conversations, runs, orchestrator agents, tools, skills, output audits, sub-agents, and persistence.
For interactive long-running work, use conversation.post(text, key:, principal:)
for next-turn input and turn.steer!(text, key:, principal:) to revise the active
plan. turn.pause!/resume! preserve progress and release background workers;
use descendants: :cascade for a research subtree. See
interactive research for exact interruption
boundaries, durable receipts, approval gates, and Rails integration.
For GPT-6 Astra tools, opt into the pinned RubyLLM 2 release candidate and
Responses protocol.
The live validation app exercises
these controls with Rails 8.1, PostgreSQL, Sidekiq, and actual Astra/xhigh requests.
For non-generative judgments, use structured evaluations. Cloudflare and OpenRouter Jev support Noul, Choice, and Score through a separate typed API with durable receipts, usage/cost accounting, and output-audit integration—not chat messages.
Add this line to your application's Gemfile:
gem "turnkit"
# Required only when using TurnKit's default RubyLLM adapter.
gem "ruby_llm", "~> 1.16"Run:
bundle installUpgrading from an earlier TurnKit version? See the 0.4.2 Upgrade Guide.
Set an API key:
export ANTHROPIC_API_KEY=...Create an agent:
require "turnkit"
agent = TurnKit::Agent.new(
name: "helper",
instructions: "Answer briefly."
)Ask a question:
turn = agent.conversation.ask("Explain Ruby blocks in one sentence.")
puts turn.output_textOr run a non-interactive application task.
run = agent.run("Explain Ruby blocks in one sentence.")
puts run.output_textFor runnable, API-key-free examples of the three core entry points, see
examples/core_api:
- conversation: durable thread over time;
- agent run: one bounded application task;
- orchestrator agent: reusable task runner with skills, tools, and limits.
For fuller orchestrator examples, see:
examples/workflow_researcher: source-grounded research with web tools, batch reads, per-tool limits, and deep monitoring;examples/amazon_memo_writer: strict memo generation with research tools, a structured terminal submit tool, deterministic format checks, and an LLM output policy.
Set a model:
TurnKit.default_model = "gpt-4.1-mini"Or configure TurnKit in one place:
TurnKit.configure do |config|
config.default_model = "gpt-4.1-mini"
config.max_spend = 0.25
config.max_iterations = 12
endSet the matching key:
export OPENAI_API_KEY=...Use these common providers:
| Provider | Key | Model |
|---|---|---|
| Anthropic | ANTHROPIC_API_KEY |
claude-sonnet-4-5 |
| OpenAI | OPENAI_API_KEY |
gpt-4.1-mini |
| Gemini | GEMINI_API_KEY |
gemini-2.5-flash |
| OpenRouter | OPENROUTER_API_KEY |
openrouter/... |
Expect TurnKit::ModelAccessError for obvious key mistakes.
To run eligible coding tasks against a ChatGPT Plus/Pro Codex subscription instead of provider API-key billing, use the Codex adapter. It shells out to the official codex exec CLI, so authenticate Codex first:
codex login --device-authThen configure TurnKit:
TurnKit.configure do |config|
config.client = TurnKit::Adapters::Codex.new(sandbox: "read-only")
config.default_model = "gpt-5.4"
endThe Codex adapter does not store ChatGPT tokens or read ~/.codex/auth.json directly. It reuses Codex CLI auth and records token usage with no TurnKit provider cost, because usage is charged against the user's ChatGPT/Codex plan limits.
Create a conversation:
agent = TurnKit::Agent.new(
name: "writer",
instructions: "Write clear release notes."
)
conversation = agent.conversation(subject: "v1 launch")Add context:
conversation.say("Mention faster tool execution.")Run the agent:
turn = conversation.run!
puts turn.output_textUse Agent#run when your application needs one non-interactive result. A run is
the AI equivalent of a service object call: one input, one job, one output.
Reach for a run when the task is bounded, such as classification, extraction, summarization, routing, scoring, or structured JSON generation.
agent = TurnKit::Agent.new(
name: "lead_classifier",
instructions: "Classify leads and return routing data.",
output_schema: {
type: "object",
properties: {
priority: { type: "string" },
reason: { type: "string" }
},
required: ["priority", "reason"]
},
)
run = agent.run(
"Classify this lead.",
input: { company: "Acme", employees: 1_200 }
)
puts run.output_dataAgent#run uses task prompt behavior by default: it treats the input as the
contract, avoids follow-up questions, and returns the best result it can. It is a
small wrapper over TurnKit's existing conversation and turn engine. Existing
conversation.ask usage is still supported for multi-turn threads.
Prepare a pending run without calling the model:
run = agent.run("Classify later.", async: true)
request = run.preview
run.run!Use an orchestrator agent when a run graduates into a reusable production capability: a named task runner with skills, tools, defaults, guardrails, compaction, and output policy.
Orchestrator agents fight for their life when the task has a repeatable operating procedure: inspect app data, gather context, use sources, draft, verify, save, and stop under budget. They are overkill for simple classification or extraction runs.
source_grounded_brief = TurnKit::Skill.from_file("app/ai/skills/source_grounded_brief.md")
agent = TurnKit::Agent.new(
name: "brief_writer",
instructions: "Create source-grounded briefs and verify claims before final output.",
skills: [source_grounded_brief],
tools: [WebSearch.new, ReadWebPage.new, SaveBrief],
max_spend: 0.25,
max_iterations: 12,
max_tool_executions: 25,
max_tool_executions_by_name: {
web_search: 2,
read_web_page: 8
},
compaction: {
context_limit: 64_000,
threshold: 0.75
},
orchestrator: true
)
run = agent.run(
"Create a source-grounded brief.",
input: { topic: "Rails 8 Solid Queue" }
)
puts run.output_text
puts run.tool_executions.map(&:tool_name)
puts run.cost.totalThis keeps the work in a single conversation and uses TurnKit's normal model-tool loop:
model → tool → result → model → tool → result → final
For repeated orchestrator runs, keep instructions, skills, and tools stable and pass the
per-run data through input:. This gives provider prompt caching the best chance
to reuse the stable agent prompt while each run supplies dynamic data.
Use the smallest entry point that matches the shape of work:
| Entry point | Use when | Tradeoffs |
|---|---|---|
Conversation |
A user or app will keep adding messages over time. | Best for durable threads and follow-up steering; history grows, so long threads need compaction. |
Agent#run |
Your app needs one bounded result now. | Best for simple production tasks; repeated complex policies can sprawl across callers. |
Agent.new(orchestrator: true) |
A task becomes a named reusable agent with tools, skills, limits, and observability. | Best cache and packaging story for repeated autonomous work; overkill for one-off/simple tasks. |
Prompt caching and compaction solve different problems:
- prompt caching reduces the cost of repeated stable instructions, tools, and skills;
- compaction reduces the cost of long dynamic histories;
- budgets (
max_spend,max_iterations,max_tool_executions) keep autonomous loops bounded.
Use max_tool_executions_by_name when an orchestrator needs different budgets for
different tools. For example, allow many cheap reads but only one final submit
tool, or cap web searches while allowing a batch page reader.
Reach for separate agents and sub_agents only when the isolation is worth the
extra model calls, such as different models, different tool permissions,
parallel specialist review, or separate durable child conversations.
Run an orchestrator agent with run:
outreach_agent = TurnKit::Agent.new(
name: "outreach_writer",
instructions: "Create compliant outreach for accounts.",
max_spend: 0.25,
max_iterations: 8,
max_tool_executions: 20,
compaction: {
context_limit: 64_000,
threshold: 0.75
},
orchestrator: true
)
run = outreach_agent.run(
"Create compliant outreach for this account.",
input: lead.attributes
)Use terminal! for save or action tools that complete the run:
class SaveBrief < TurnKit::Tool
description "Save the final brief."
parameter :title, :string, required: true
parameter :body, :string, required: true
terminal! { |result| "Saved #{result.fetch("id")}." }
def call(title:, body:, context:)
Brief.create!(title: title, body: body).then { |brief| { id: brief.id } }
end
endBy default, reaching max_spend stops the turn before further work. A model
response can itself reach or exceed that limit: its usage and proposed tool
calls are persisted, but an ordinary terminal tool is not allowed to run.
For a local, idempotent final save only, explicitly opt in on the tool:
class SavePacket < TurnKit::Tool
terminal! { |result| "Saved #{result.fetch('id')}." }
recovery :replay_safe
budget_completion!
# Define parameters and call normally. Validate the complete packet before
# saving; persist context.idempotency_key with the save in one transaction.
endbudget_completion! requires both terminal behavior and recovery :replay_safe.
It is an application promise that the tool only validates/saves already acquired
output locally: no model calls, acquisition, or child launches. Terminal status
alone never grants this exception, and the marker is not inherited implicitly.
When the billed response reaches the spend limit, TurnKit atomically records the sole eligible call ID with that response. The normal tool runner executes only that call; other calls in the batch get skipped receipts and paired tool results, even if they precede the save. Zero or multiple eligible calls fail without tool dispatch. No new model request is allowed in the exhausted turn, including compaction, model-backed output audits, image generation, or media analysis.
Authorization, argument validation, claim fencing, cancellation, timeout/depth,
and global/per-tool execution limits still apply. Invalid saves must raise
ToolValidationError/ToolError; an ordinary error-shaped hash is still tool
result data. Failed receipts are never replayed or repaired with another model
call. Failed local output audits also terminate rather than request revision;
model-backed audits cannot run at exhaustion. Use local validation for this path.
Worker recovery reuses the persisted call/execution and its idempotency key; completed receipts are not re-executed. The application must make the save and its receipt atomic. An already-started external effect cannot be recalled by cancellation, so this marker must not be applied to acquisition tools. No schema migration is needed; upgrade workers together before enabling the opt-in.
Use output audits for deterministic checks that should not depend on another model call: required headings, source counts, forbidden characters, JSON shape, or project-specific formatting rules.
no_em_dash = ->(output) do
next unless output.include?("—")
{ rule: "no_em_dash", message: "contains an em dash" }
end
numbered_lists_only = ->(output) do
lines = output.lines.each_with_index.filter_map do |line, index|
index + 1 if line.match?(/^\s*[-*]\s+/)
end
next if lines.empty?
{
rule: "numbered_lists_only",
message: "contains unordered list markers",
metadata: { lines: lines }
}
end
agent = TurnKit::Agent.new(
name: "memo_writer",
output_policy: [no_em_dash, numbered_lists_only],
output_policy_mode: :fail,
orchestrator: true
)Run checks directly when you want to test a renderer or policy without calling a model:
audit = TurnKit.check_output_policy(
"1. Recommendation\n- unordered item — fix this\n",
constraints: [no_em_dash, numbered_lists_only]
)
puts audit.clean?
puts audit.messagesUse output_policy when a semantic judge is worth the extra model call. The
policy can be a .md, .markdown, or .txt file path, a TurnKit::Skill, a
TurnKit::OutputPolicy, or any object that responds to #call or #check.
agent = TurnKit::Agent.new(
name: "memo_writer",
output_policy: "app/ai/policies/amazon_memo.md",
output_policy_model: "gpt-4.1-mini",
output_policy_thinking: { effort: :low },
output_policy_mode: :report,
orchestrator: true
)output_policy_mode: :report records violations while allowing the run to
complete. :fail marks the run failed after recording the output and audit;
:fail is the default for contract-driven orchestrator runs. Policy model usage and
cost are counted on the parent run.
Add output_retries: to turn policy failures into bounded revision loops instead
of dead ends:
voice = TurnKit::Skill.from_file("app/ai/skills/memo_voice.md")
agent = TurnKit::Agent.new(
name: "memo_writer",
skills: [voice],
output_policy: [voice, no_em_dash],
output_retries: 2,
input_schema: {
"type" => "object",
"required" => ["project_id"],
"properties" => { "project_id" => { "type" => "string" } }
},
orchestrator: true
)skills: are always loaded into the prompt. available_skills: are listed in
<skills_available> and exposed through the load_skill tool, so the model can
load full instructions on demand. Every advertised tool call receives exactly one
tool result, including validation errors, budget denials, and calls skipped after
a terminal tool ends the turn.
Preview a pending turn:
turn = conversation.ask("Draft the launch email.", async: true)
request = turn.previewInspect the request:
request.model
request.messages
request.tool_names
request.instructions
request.reportRun the reviewed turn:
turn.run!Customize generated prompts with system_prompt: on an agent. A string replaces
the generated prompt. A callable receives the built TurnKit::SystemPrompt and
returns the final string:
agent = TurnKit::Agent.new(
name: "reporter",
system_prompt: ->(prompt) {
[prompt.stable, prompt.section(:tools), prompt.dynamic].reject(&:empty?).join("\n\n")
}
)TurnKit::SystemPrompt supports to_s, section(:tools), stable, and
dynamic. prompt_sections:, TurnKit.prompt_sections,
TurnKit.prompt_behavior, and TurnKit.context_contributors remain available
for generated prompts.
Generated prompts keep stable instructions separate from subject, live context,
and environment. With the RubyLLM OpenAI adapter (Responses or Chat Completions),
TurnKit persists each changed full context snapshot as a dynamic_context
conversation message before model dispatch. It replays older snapshots unchanged
and appends new ones after completed tool exchanges. They render as labeled user
reference-data messages, not new user requests or top-level instructions;
the newest snapshot replaces earlier snapshots for current state. Keep policies
in stable instructions and supply current data through context contributors.
Identical snapshots are deduplicated against durable, model-visible history, including after retry/resume. Empty context clears a previous snapshot. Changed context adds history, so keep contributors concise; compaction can remove old snapshots and resets that portion of the prefix. Always supply the full current context, not deltas. Changing tools, skills, schemas, model settings, or stable instructions can also invalidate a prefix. Existing histories are not rewritten.
Custom clients retain the separate instructions / dynamic_instructions
contract. Clients opting into dynamic_context_in_history?(model:) receive
snapshots in messages and empty dynamic_instructions. Other clients do not
receive these historical snapshot messages. Raw Conversation#messages includes
them; public messages_after progress reads exclude them. No schema migration is
needed, but custom message-kind allowlists must accept dynamic_context, and
workers reading those conversations must be upgraded together.
Direct adapter.chat callers still receive fresh dynamic context at the tail,
but must preserve prior snapshots themselves (using
TurnKit::MessageProjection.dynamic_context(text) in their own history) and
avoid supplying the same snapshot again through dynamic_instructions.
A custom system_prompt: string/callable is treated entirely as stable
instructions; recombining prompt.dynamic there bypasses this protection.
TurnKit.prompt_cache = :auto enables TurnKit's existing Anthropic cache markers;
:off suppresses those markers. It is not an OpenAI cache-disable switch.
OpenAI implicit caching remains provider-default in either setting, including
when RubyLLM uses store: false or a fresh chat object. TurnKit does not force
explicit breakpoints, cache keys, or TTLs. Matching wire prefixes make reuse
possible, not guaranteed: eligibility, routing, lifetime, and model-specific
boundaries still matter. See OpenAI's prompt-caching guide
and measure actual cache reads/writes before claiming savings.
Create a tool:
class SaveReport < TurnKit::Tool
description "Save a report."
usage_hint "Use when the user asks to persist a report."
parameter :title, :string, required: true
parameter :body, :string, required: true
terminal! do |result|
"Saved #{result.fetch("report_id")}."
end
def call(title:, body:, context:)
{ report_id: "rep_1", title: title, body: body }
end
endRegister the tool:
agent = TurnKit::Agent.new(
name: "reporter",
instructions: "Save reports when asked.",
tools: [SaveReport]
)Run the tool loop:
turn = agent.conversation.ask("Save a short status report.")
puts turn.output_textRely on TurnKit to validate tools and model-provided arguments.
Gate tool calls per agent for routing or cost, separately from identity authorization:
agent = TurnKit::Agent.new(
name: "reporter",
tools: [ReadFile, BulkRead],
tool_policy: lambda do |tool:, arguments:, context:|
next :allow unless tool.is_a?(ReadFile) && File.foreach(arguments["path"]).count > 350
[:block, "File is large. Use `bulk_read` with a question, or re-read with `offset`/`limit`."]
end
)The policy runs after authorization and before the tool executes. Return
:allow (or nil) to proceed, or [:block, reason] to return the reason to
the model as a tool error with details["tool_policy_blocked"] = true. Keep
authorization_policy for who may call what; use tool_policy for how much and
which way.
Generate images inside a durable turn with turn.paint. The image call uses the
configured client adapter, records usage and cost on the turn, persists an image
message, and emits image.requested / image.completed events.
image = turn.paint(
"Create a 16:9 editorial header image for the article.",
model: "gemini-3-pro-image-preview",
provider: :gemini,
size: "1024x576",
metadata: { article_id: article.id }
)
image.url # provider-hosted URL when returned
image.to_blob # generated bytes for base64 responses, or fetched URL bytes
image.mime_type # "image/png"For reusable agent steps, subclass TurnKit::ImageTool:
class GenerateHeaderImage < TurnKit::ImageTool
description "Generate an article header image."
parameter :title, :string, required: true
model "gemini-3-pro-image-preview"
provider :gemini
size "1024x576"
def prompt(title:)
"Create a 16:9 editorial header image for #{title}."
end
endRails apps can attach generated images from the event stream without TurnKit taking a dependency on Active Storage:
TurnKit.on_event = ->(event) do
next unless event.type == "image.completed"
image = TurnKit::ImageResult.from_h(event.payload.fetch(:image))
Article.find(event.payload.dig(:metadata, :article_id)).header_image.attach(
io: StringIO.new(image.to_blob),
filename: "header.png",
content_type: image.mime_type
)
endRequire an image before completion with TurnKit::OutputPolicy.require_image.
For editing or style references, override input_images(**arguments) and
mask(**arguments) in your ImageTool subclass, just like prompt and
metadata. Both default to nil and are forwarded to the image provider.
Declare the corresponding tool parameters yourself; accepted sources, reference
limits, and mask support depend on the provider. Local paths must exist on the
executing worker. Generate only when the application/user requested it; use
ViewMediaTool for inspection, not image generation.
Image results retain URL or base64 bytes, including in tool results. Provider URLs may expire: use the event hook above to copy images into application-owned storage for durable user-facing links. TurnKit does not host attachments or automatically make generated images visually available to the next text model call. Base64 tool results also consume prompt space; for large images, prefer a custom tool that stores the artifact and returns its application-owned URL.
Analyze existing images, PDFs, audio, or video inside a durable turn with
turn.view_media. Media inputs can be local paths, URLs, IO-like objects,
TurnKit::MediaInput.bytes(...), or Rails Active Storage blobs/attachments.
TurnKit records usage and cost on the turn, persists a media analysis message,
and emits media.requested / media.completed / media.failed events.
analysis = turn.view_media(
article.header_image,
objective: "Verify this generated header matches the article art direction.",
model: "gemini-2.5-pro",
provider: :gemini,
metadata: { article_id: article.id }
)
analysis.text # text analysis
analysis.data # structured output when requested
analysis.media # normalized media metadataFor bytes, provide a MIME type so adapters can pass the media correctly:
media = TurnKit::MediaInput.bytes(
File.binread("header.png"),
mime_type: "image/png",
filename: "header.png"
)For reusable agent steps, subclass TurnKit::ViewMediaTool:
class ReviewHeaderImage < TurnKit::ViewMediaTool
description "Review a generated article header image."
parameter :article_id, :integer, required: true
model "gemini-2.5-pro"
provider :gemini
def media(article_id:)
Article.find(article_id).header_image
end
def objective(article_id:)
"Review this generated image against the article art direction."
end
def metadata(article_id:)
{ article_id: article_id }
end
endRequire a media review before completion with
TurnKit::OutputPolicy.require_media_analysis. TurnKit persists media metadata
and analysis text, not raw media bytes.
Define a schema:
schema = {
type: "object",
properties: {
title: { type: "string" },
bullets: {
type: "array",
items: { type: "string" }
}
},
required: ["title", "bullets"]
}Use structured output:
agent = TurnKit::Agent.new(
name: "writer",
output_schema: schema
)
turn = agent.conversation.ask("Summarize the launch plan.")
puts turn.output_dataOverride the schema per turn:
conversation.ask(
"Return one decision.",
output_schema: {
type: "object",
properties: {
decision: { type: "string" }
}
}
)Subscribe globally:
TurnKit.on_event = ->(event) do
Rails.logger.info("turnkit.#{event.type}")
endSubscribe per agent:
agent = TurnKit::Agent.new(
name: "helper",
on_event: ->(event) { puts event.type }
)Subscribe per turn:
turn.run! do |event|
puts event.type
endUse events for turns, model calls, messages, and tool calls.
Load a skill:
skill = TurnKit::Skill.from_file("skills/research.md")Use the skill:
agent = TurnKit::Agent.new(
name: "researcher",
skills: [skill]
)Create a sub-agent:
writer = TurnKit::Agent.new(
name: "writer",
description: "Draft concise copy."
)Register the sub-agent:
editor = TurnKit::Agent.new(
name: "editor",
sub_agents: [writer]
)Ask the parent agent:
turn = editor.conversation.ask("Ask the writer for three headlines.")
puts turn.output_textUse sub-agents for isolated child conversations.
Subclass SubAgentTool to build the child task from typed arguments so bulk
data never enters the parent's context:
class BulkRead < TurnKit::SubAgentTool
agent reader
description "Read files and answer a question about them."
parameter :question, :string, required: true
parameter :paths, :array, required: true, items: :string
def task_for(question:, paths:)
files = paths.map { |path| "<file path=\"#{path}\">\n#{File.read(path)}\n</file>" }
"#{question}\n\n#{files.join("\n")}"
end
endThe parent model supplies question and paths; task_for runs inside the
runtime, and only the child's answer returns to the parent. Override agent on
an instance when the child agent is configured at runtime. Each delegation emits
sub_agent.delegated with task_chars, so avoided parent context is
measurable. See examples/shunt for a complete routing setup.
Use ordinary agents, not a second specialist runtime. Give each specialist its
own model, instructions, description (when the parent should invoke it), and
explicit tool list, then include it in the parent's sub_agents:
- Oracle: instructions for a specific unresolved decision, the evidence already checked, constraints, and a recommendation. Supply only scoped read/search tools; the parent owns implementation and verification.
- Librarian: instructions for external repository understanding and a full evidence-backed report. Supply authenticated repository read/search, history, diff, and issue-reading tools appropriate to the application. A role name does not provide GitHub access. Exclude repository and issue mutation tools.
- Painter: use
ImageToolfor an actual image-provider call rather than a text agent pretending to generate an image.
Children receive the explicit task, not the parent's conversation history or
tool list. Their complete final text, structured output_data, status/error,
and conversation/turn IDs return through the parent's matching tool result;
intermediate child messages remain in the child conversation. Inline calls
block; background calls use the same durable fan-out/join machinery below.
This is a context boundary, not an OS or credential sandbox. Global context contributors and custom clients can supply additional context/capabilities; scope those deliberately. Enforce read-only access in the supplied tools and credentials, not merely in prompts. Likewise, enforce user approval for image generation in application tool exposure/authorization when it is a requirement. TurnKit does not ship Amp's private tools, repository service, or attachment host.
Durable background execution uses Active Record/Active Job 7.2+, ActiveRecordStore, and a
persistent backend such as Solid Queue or Sidekiq. Configure the backend in your
application; TurnKit does not start worker processes. The database owns pending
work, while jobs are wake-up signals. MemoryStore is for inline use and tests.
Existing installations must run bin/rails generate turnkit:upgrade and migrate
before using this version. New installations use turnkit:install as usual.
Register agent definitions at application boot in every web and worker
process. Registration also registers configured sub-agents. Names must identify
the same agent configuration in every process; use Rails to_prepare when
definitions are reloadable. TurnKit persists IDs, model, options, and lineage,
not Ruby clients, tools, closures, or executable configuration.
Subject to_prompt data is snapshotted in conversation metadata for worker
reconstruction. Use live-context contributors with application-owned identifiers
when background turns need freshly loaded domain objects rather than that snapshot.
require "turnkit/job"
writer = TurnKit::Agent.new(name: "writer", instructions: "Draft concise copy.")
editor = TurnKit.register(TurnKit::Agent.new(
name: "editor",
sub_agents: [writer],
tools: [TurnKit::LaunchAgentTool, TurnKit::SendMessageTool, TurnKit::WaitTool]
))
run = editor.run("Research and draft the announcement.", async: true)
run.perform_later
# Another process can reconstruct records using the registered definitions.
run = TurnKit::Run.new(TurnKit.load_turn(run.id))
run.reload.statusasync: true still means prepare pending work, not enqueue it. This preserves
preview-only use. perform_later persists submission before enqueueing and
returns immediately. Background work uses the application-wide TurnKit.store.
Worker clients and tools must be safe for the concurrency configured in your job
backend. Do not launch unjoined Ruby threads from tools.
In a background turn, a group of consecutive sub-agent tool calls launches
independent child jobs. The parent becomes waiting and releases its worker;
once all children finish, it resumes with tool results in call order. A failed
child returns a structured failure, not an implicit parent failure. Ordinary
tools remain sequential, and terminal tools prevent later calls from running.
Inline turns use the same engine but execute sub-agents synchronously.
LaunchAgentTool starts a configured sub-agent without waiting. Its optional
callback: true requests a completion message in the launching conversation.
WaitTool joins submitted turns without holding a worker. These coordination
tools are opt-in; the application owns authorization and should expose only
the destinations/actions appropriate for that agent.
Application code can send messages and request completion callbacks too:
parent = TurnKit.load_conversation(parent_conversation_id)
child = writer.run("Investigate the issue.", async: true)
child.perform_later(callback: parent)
parent.send_message(other_conversation_id, "Please check the latest results.",
key: "request-123:check-results")
parent.inbox
parent.outbox
# Attach dependencies before enqueueing, avoiding a race with an eager worker.
summary = editor.run("Summarize the joined results.", async: true)
summary.wait_for(child).perform_laterDelivery keys are globally unique idempotency keys. Retrying the same key returns
the original delivery; it does not replace its payload. Inbox, outbox, and
completion callbacks use the same persisted delivery rows. Delivery appends one
message and records the need for a continuation transactionally. Messages to a
running or waiting conversation do not change its current input snapshot: they
are consumed by a later turn. Background turns in one conversation execute
serially. Use TurnKit.load_conversation to address a conversation independently
of a worker's fenced execution context.
Application-level wait_for supplies joined results as turn-local input before
the first model call. Attach waits only to pending turns. Wait targets must be
submitted or finished, and must not form dependency cycles. Waiting for unfinished
work in the same conversation is rejected because execution there is serial.
Schedule TurnKit::ReconcileJob using your backend's recurring-job facility
(for example every minute). This repairs missed enqueues, undelivered messages,
ready joins, and abandoned workers. TurnKit.reconcile_stale! also recovers
submitted work while retaining the inline stale-turn behavior described below.
- Claims are fenced: after revocation or completion, an old execution cannot persist another model response, tool result, heartbeat, or completion.
- Recovery reuses committed model responses and tool results. An interrupted model request may be reissued and charged again by the provider.
- Started external tool calls without results become
interrupted, with unknown outcomes. They are not replayed. The built-in durable coordination tools can resume using their existing child IDs/delivery keys. - Waiting is not a stale heartbeat. Root timeout still applies, including queue and wait time from initial submission. It is a cooperative limit, not a hard process kill. Use provider/tool timeouts for blocking external calls.
- Root iteration and tool-count limits are reserved under a database lock across parallel children. Spend limits use observed cost; already-running requests can overshoot them.
on_eventremains an inline observation hook, not a durable callback. Background infrastructure exceptions reach the job backend rather than being converted into success. Monitor failed jobs and run reconciliation.- TurnKit fences its own persistence, not arbitrary external side effects. Tools
should use
context.turn.storefor execution-owned TurnKit writes. External services need their own idempotency keys where appropriate.
The PostgreSQL integration suite exercises real row locks and worker-process death. Run it against a dedicated test database:
TURNKIT_TEST_DATABASE_URL=postgresql:///turnkit_test bundle exec rake testIt creates/rebuilds only turnkit_test_* and turnkit_upgrade_test_* test tables.
Without the variable, database tests explicitly skip. Custom stores must implement
the transactional Store contract, including reentrant atomic, deliveries,
waits, and submitted-turn queries; a mutex alone is not cross-process durability.
Disable compaction:
TurnKit.compaction = falseConfigure compaction:
TurnKit.compaction = {
model: "gpt-4.1-mini",
threshold: 0.75,
context_limit: 128_000
}Compact manually:
conversation.compact!(focus: "billing migration")Install Rails persistence:
bin/rails generate turnkit:installRun migrations:
bin/rails db:migrateUse this layout:
app/ai/agents/
app/ai/tools/
app/ai/skills/
Use custom Active Record classes by passing class names to the store:
TurnKit.store = TurnKit::ActiveRecordStore.new(
conversation_class: "My::Conversation",
turn_class: "My::Turn",
message_class: "My::Message",
tool_execution_class: "My::ToolExecution"
)Reconcile turns abandoned by a dead worker (for example after a hard-killed process). Run this periodically:
TurnKit.reconcile_stale!For unsubmitted inline work, reconciliation atomically marks pending and running
turns whose last heartbeat
is older than TurnKit.timeout as stale, so it never overwrites a turn that
was concurrently claimed, heartbeated, or completed. Each stale turn's
unfinished tool executions become interrupted, and a synthetic error tool
result is appended for any unresolved tool call so the conversation can be
continued. TurnKit never reruns an interrupted tool — whether its side effect
happened is unknown, so the continued model is told not to assume either way.
Reconciliation revokes the original worker's commit authority. A late worker
cannot replace stale with its own outcome. It does not kill the underlying
process or undo external side effects. Continue an inline stale conversation
with a new turn; submitted background work is resumed automatically.
| Option | Description |
|---|---|
TurnKit.default_model |
Set the default model. |
TurnKit.client |
Set the model client. |
TurnKit.store |
Set the persistence store. |
TurnKit.max_iterations |
Limit model loop iterations. |
TurnKit.max_depth |
Limit sub-agent depth. |
TurnKit.max_tool_executions |
Limit tool calls per turn. |
TurnKit.max_tool_executions_by_name |
Limit specific tools independently. |
TurnKit.timeout |
Limit turn runtime. |
TurnKit.max_spend |
Limit estimated turn cost. |
TurnKit.compaction |
Configure context compaction. |
TurnKit.output_policy_model |
Default model for file-backed output policies. |
TurnKit.output_policy_thinking |
Default thinking config for file-backed output policies. |
TurnKit.on_event |
Subscribe to lifecycle events. |
Set options globally:
TurnKit.default_model = "gpt-4.1-mini"
TurnKit.max_spend = 0.25
TurnKit.max_iterations = 25
TurnKit.max_tool_executions_by_name = { web_search: 2 }
TurnKit.output_policy_model = "gpt-4.1-mini"
TurnKit.timeout = 300max_spend is the only spend-limit name in the public API.
Spend equal to the limit is exhausted, not just spend above it. Dispatch checks
use persisted aggregate spend, including after recovery and before internal
model/media calls. Committed response cost is retained even when the turn fails.
Customize cost rates with USD-per-million-token component keys:
TurnKit.cost_rates["custom-model"] = {
input: 0.15,
output: 0.60,
cache_read: 0.03,
cache_write: 0.18,
thinking: 0.60
}Custom clients should subclass TurnKit::Client or accept the full #chat
keyword contract, including dynamic_instructions:. Adapters that support prompt
caching should cache instructions and append dynamic_instructions per turn.
Custom stores must implement claim_turn atomically.
Set options per agent:
agent = TurnKit::Agent.new(
name: "engineer",
model: "gpt-4.1-mini",
max_iterations: 10,
max_depth: 2
)Enable thinking:
agent = TurnKit::Agent.new(
name: "reasoner",
model: "claude-sonnet-4-5",
thinking: { budget: 4_000 }
)See runtime hardening for scoped context, authorization, cancellation, cycle-safe waits, replay-safe effects and bounded maintenance, and specialists and skills for the extensible Oracle, Librarian and Painter factories and skill-owned tools.
See the 0.4.2 Upgrade Guide for the full API migration checklist.
Rails installs from older versions may need output_data for structured output,
image, and media-analysis persistence.
add_column :turnkit_turns, :output_data, :jsonSkip this step for new installs.
Fork the project.
Run tests:
bundle exec rake testRun syntax checks:
find lib test examples -type f -name '*.rb' -print0 | xargs -0 ruby -cOpen a pull request.
Maintainers: follow the release guide for RubyGems trusted
publishing setup, version and lockfile updates, and the explicit tag-push procedure.
Pushing a v* tag triggers tests and publication through the release environment.
Use this gem under the MIT License.