TurnKit
Build durable Ruby and Rails agents with tools, skills, sub-agents, and persistence.
Installation
Add this line to your application's Gemfile:
gem "turnkit"
Run:
bundle install
Upgrading from an earlier TurnKit version? See the Upgrade Guide.
Quick Start
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_text
Or run a non-interactive application task:
run = agent.run("Explain Ruby blocks in one sentence.")
puts run.output
Usage
Models
Set a model:
TurnKit.model = "gpt-4.1-mini"
Or configure TurnKit in one place:
TurnKit.configure do |config|
config.model = "gpt-4.1-mini"
config.max_spend = 0.25
config.max_iterations = 12
end
Set 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.
Conversations
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_text
Application Tasks
Use Agent#run when your application is executing a task instead of chatting
with a user:
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"]
},
prompt_mode: :task
)
run = agent.run(
"Classify this lead.",
input: { company: "Acme", employees: 1_200 }
)
puts run.output_data
Agent#run is a small wrapper over TurnKit's existing conversation and turn
engine. Existing conversation.ask usage is still supported.
Prepare a pending run without calling the model:
run = agent.run(task: "Classify later.", async: true)
request = run.preview
run.run!
Fleets
Use a fleet when you want to package a reusable autonomous workflow: one task-mode orchestrator, workflow skills, tools, defaults, and guardrails. A fleet is not a requirement for multi-agent work; it is the reusable runtime for getting from input to output.
source_grounded_brief = TurnKit::Skill.from_file("app/ai/skills/source_grounded_brief.md")
fleet = TurnKit.fleet(
"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,
compaction: {
context_limit: 64_000,
threshold: 0.75
}
)
run = fleet.run(
"Create a source-grounded brief.",
input: { topic: "Rails 8 Solid Queue" }
)
puts run.output
puts run.tool_calls.map(&:tool_name)
puts run.cost.total
This keeps the work in a single conversation and uses TurnKit's normal model-tool loop:
model → tool → result → model → tool → result → final
auto_run is an alias for run when you want the name to emphasize autonomous
execution:
run = fleet.auto_run(
"Create compliant outreach for this account.",
input: lead.attributes,
max_spend: 0.25,
max_iterations: 8,
max_tool_executions: 20,
compaction: {
context_limit: 64_000,
threshold: 0.75
}
)
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.
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
end
Prompt Preview
Preview a pending turn:
turn = conversation.ask("Draft the launch email.", async: true)
request = turn.preview
Inspect the request:
request.model
request.
request.tool_names
request.instructions
request.report
Run the reviewed turn:
turn.run!
Tools
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
def self.ends_turn? = true
def self.(result)
"Saved #{result.fetch("report_id")}."
end
def call(title:, body:, context:)
{ report_id: "rep_1", title: title, body: body }
end
end
Register 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_text
Rely on TurnKit to validate tools and model-provided arguments.
Structured Output
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_data
Override the schema per turn:
conversation.ask(
"Return one decision.",
output_schema: {
type: "object",
properties: {
decision: { type: "string" }
}
}
)
Events
Subscribe globally:
TurnKit.on_event = ->(event) do
Rails.logger.info("turnkit.#{event.type}")
end
Subscribe per agent:
agent = TurnKit::Agent.new(
name: "helper",
on_event: ->(event) { puts event.type }
)
Subscribe per turn:
turn.run! do |event|
puts event.type
end
Use events for turns, model calls, messages, and tool calls.
Skills
Load a skill:
skill = TurnKit::Skill.from_file("skills/research.md")
Use the skill:
agent = TurnKit::Agent.new(
name: "researcher",
skills: [skill]
)
Sub-agents
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_text
Use sub-agents for isolated child conversations.
Context Compaction
Disable compaction:
TurnKit.compaction = false
Configure compaction:
TurnKit.compaction = {
model: "gpt-4.1-mini",
threshold: 0.75,
context_limit: 128_000
}
Compact manually:
conversation.compact!(focus: "billing migration")
Run the local smoke test:
ruby script/manual_compaction.rb
Rails
Install Rails persistence:
bin/rails generate turnkit:install
Run migrations:
bin/rails db:migrate
Use this layout:
app/ai/agents/
app/ai/tools/
app/ai/skills/
Reconcile stale turns:
TurnKit.reconcile_stale!
Options
| 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.timeout |
Limit turn runtime. |
TurnKit.cost_limit |
Limit estimated turn cost. |
TurnKit.compaction |
Configure context compaction. |
TurnKit.on_event |
Subscribe to lifecycle events. |
Set options globally:
TurnKit.default_model = "gpt-4.1-mini"
TurnKit.max_iterations = 25
TurnKit.timeout = 300
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 }
)
Upgrading
Add output_data for structured output persistence.
add_column :turnkit_turns, :output_data, :json
Skip this step for new installs.
Contributing
Fork the project.
Run tests:
bundle exec rake test
Run syntax checks:
find lib test examples -type f -name '*.rb' -print0 | xargs -0 ruby -c
Open a pull request.
License
Use this gem under the MIT License.