TurnKit

Gem Version Ruby License

Build durable Ruby AI agents with turns, tools, skills, and Rails persistence.

Installation

Add this line to your application's Gemfile:

gem "turnkit"

Run:

bundle install

Quick Start

Set a provider key. TurnKit uses RubyLLM under the hood and defaults to Anthropic Claude:

export ANTHROPIC_API_KEY=...
Provider Env var Example 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

[!WARNING] TurnKit defaults to claude-sonnet-4-5. If ANTHROPIC_API_KEY is unset or blank, set TurnKit.default_model to a provider you have configured.

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

Usage

Models

Set the default model:

TurnKit.default_model = "claude-sonnet-4-5"

Use OpenAI:

export OPENAI_API_KEY=...

Set an OpenAI model:

TurnKit.default_model = "gpt-4.1-mini"

Use Gemini:

export GEMINI_API_KEY=...

Set a Gemini model:

TurnKit.default_model = "gemini-2.5-flash"

Thinking

Enable provider reasoning or extended thinking per agent:

agent = TurnKit::Agent.new(
  name: "reasoner",
  model: "claude-sonnet-4-5",
  thinking: { budget: 4_000 }
)

Use effort-based thinking for providers that support it:

agent = TurnKit::Agent.new(
  name: "reasoner",
  model: "gemini-2.5-flash",
  thinking: { effort: :high }
)

Override or disable thinking for one turn:

conversation = agent.conversation
conversation.ask("Solve this carefully.", thinking: { budget: 8_000 })
conversation.ask("Answer quickly.", thinking: nil)

TurnKit passes thinking to RubyLLM as { effort:, budget: }. Anthropic requires budget; Gemini and OpenRouter can use effort, budget, or both depending on the model.

When the provider reports reasoning usage, TurnKit records it as thinking_tokens and includes it in usage totals and cost calculation.

Conversations

Create a conversation:

agent = TurnKit::Agent.new(
  name: "writer",
  instructions: "Write clear release notes."
)

Add context:

conversation = agent.conversation(subject: "v1 launch")
conversation.say("Mention faster tool execution.")

Run the agent:

turn = conversation.run!
puts turn.output_text

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.completion_message(result) = "Saved #{result.fetch("report_id")}."

  def call(title:, body:, context:)
    { report_id: "rep_1", title: title, body: body }
  end
end

Use the tool:

agent = TurnKit::Agent.new(
  name: "reporter",
  instructions: "Save reports when asked.",
  tools: [SaveReport]
)

Ask for tool use:

turn = agent.conversation.ask("Save a short status report.")
puts turn.output_text

Defining application tools

Tools are classes, not instances. Namespaced tools work fine, and the default tool name comes from the class name: Assistant::Tools::WebSearch becomes web_search.

module Assistant
  module Tools
    class WebSearch < TurnKit::Tool
      description "Search the web for current information."
      usage_hint "Use when current external information is needed."

      parameter :objective, :string, required: true
      parameter :search_queries, :array, required: false

      def call(objective:, search_queries: nil, context:)
        ParallelClient.new.web_search(
          objective: objective,
          search_queries: search_queries
        )
      end
    end
  end
end

Register tool classes on the agent:

agent = TurnKit::Agent.new(
  name: "researcher",
  tools: [
    Assistant::Tools::WebSearch,
    Assistant::Tools::ReadWebPage
  ]
)

Tool context

Every tool receives a context: object. Use it for logging, correlation, persistence, and domain scoping:

def call(query:, context:)
  context.turn       # The TurnKit::Turn being run
  context.execution  # The TurnKit::ToolExecution for this tool call

  { query: query }
end

If your application already uses a context: keyword for something else, use turnkit_context: instead:

def call(query:, turnkit_context:)
  { turn_id: turnkit_context.turn.id, query: query }
end

Tool return values

Prefer returning a Hash. TurnKit serializes the normalized value as the tool result:

Return value Stored tool result
Hash Keys are stringified.
Array Wrapped as { "items" => [...] }.
Scalar Wrapped as { "result" => value.to_s }.

Avoid returning arbitrary objects unless you convert them to a plain Hash or Array first.

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."
)

Delegate to it:

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

Usage and costs

Inspect token usage:

turn.usage.total_tokens
conversation.usage.total_tokens
agent.usage.total_tokens

Inspect costs:

turn.cost.total
conversation.cost.total
agent.cost.total

Use RubyLLM registry prices by default.

Override model rates:

TurnKit.cost_rates = {
  "my-model" => {
    input: 0.25,
    output: 1.00,
    cached_input: 0.05,
    cache_creation: 0.25
  }
}

Override cost calculation:

TurnKit.cost_calculator = ->(usage, model) do
  {
    input: usage.input_tokens * 0.25 / 1_000_000.0,
    output: usage.output_tokens * 1.00 / 1_000_000.0
  }
end

Limit turn cost:

agent = TurnKit::Agent.new(
  name: "analyst",
  cost_limit: 0.25
)

Prompt caching

Enable prompt caching:

TurnKit.prompt_cache = :auto

Disable prompt caching:

TurnKit.prompt_cache = :off

Split custom prompts:

agent = TurnKit::Agent.new(
  name: "cached",
  system_prompt: [
    "Stable instructions and tool guidance.",
    TurnKit::SystemPrompt::CACHE_BOUNDARY,
    "Dynamic subject and live context."
  ].join("\n")
)

Custom clients

Create a client:

class MyClient < TurnKit::Client
  def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
    TurnKit::Result.new(
      text: "provider response",
      model: model,
      usage: TurnKit::Usage.new(
        input_tokens: 100,
        output_tokens: 20,
        cached_tokens: 80,
        cache_write_tokens: 100
      )
    )
  end
end

Use the client:

TurnKit.client = MyClient.new

Split cache sections:

stable, dynamic = TurnKit::SystemPrompt.split_cache_boundary(instructions)

Rails

Install Rails persistence:

bin/rails generate turnkit:install

The installer creates:

  • config/initializers/turnkit.rb
  • app/models/turnkit/conversation.rb
  • app/models/turnkit/turn.rb
  • app/models/turnkit/message.rb
  • app/models/turnkit/tool_execution.rb
  • a migration for TurnKit persistence

The generated migration currently uses ActiveRecord::Migration[7.1]. In a newer Rails app, update that version if your app requires it, for example ActiveRecord::Migration[8.1].

Run migrations:

bin/rails db:migrate

Configure Rails:

TurnKit.store = TurnKit::ActiveRecordStore.new

Suggested Rails file layout for your application AI code:

app/models/assistant/
  tools/
    web_search.rb
    read_web_page.rb
  skills/
  prompts/

If you prefer to keep AI infrastructure out of app/models, add an autoloaded directory such as:

app/ai/
  tools/
  skills/
  prompts/

Reconcile stale turns:

TurnKit.reconcile_stale!

Debugging Rails persistence

Inspect the latest persisted turn in a Rails console:

turn = Turnkit::Turn.order(created_at: :desc).first
turn.status
turn.error
turn.output_text

Check whether the model actually called tools:

Turnkit::ToolExecution
  .where(turn_uid: turn.uid)
  .order(:created_at)
  .map { |execution|
    {
      name: execution.tool_name,
      status: execution.status,
      arguments: execution.arguments,
      result_keys: execution.result&.keys,
      error: execution.error
    }
  }

Live smoke test

Use a model whose provider key is configured, then run a real tool-using turn:

TurnKit.default_model = "gpt-4.1-mini"

agent = TurnKit::Agent.new(
  name: "researcher",
  instructions: "Use web_search, then read_web_page, before answering.",
  tools: [
    Assistant::Tools::WebSearch,
    Assistant::Tools::ReadWebPage
  ]
)

turn = agent.conversation.ask(
  "Search for the TurnKit Ruby gem, read the first useful result, then summarize it."
)

puts turn.output_text

pp Turnkit::ToolExecution
  .where(turn_uid: turn.id)
  .order(:created_at)
  .pluck(:tool_name, :status, :error)

Options

Configure defaults:

TurnKit.default_model = "claude-sonnet-4-5"
TurnKit.max_iterations = 25
TurnKit.timeout = 300
TurnKit.max_depth = 3
TurnKit.max_tool_executions = 100
TurnKit.cost_limit = nil
TurnKit.cost_rates = {}
TurnKit.cost_calculator = nil
TurnKit.prompt_cache = :auto

Override an agent:

agent = TurnKit::Agent.new(
  name: "analyst",
  model: "gpt-4.1-mini",
  max_iterations: 10,
  timeout: 60,
  cost_limit: 0.25,
  thinking: { effort: :low }
)
Option Description
default_model Set the default RubyLLM model.
client Set the model client.
store Set the conversation store.
max_iterations Limit model calls per turn.
timeout Limit seconds per root turn.
max_tool_executions Limit tool calls per root turn.
cost_limit Limit cost per root turn.
thinking Configure provider reasoning or extended thinking per agent.
cost_rates Override prices by model.
cost_calculator Override cost calculation.
prompt_cache Use provider prompt caching.

Contributing

Report bugs and open pull requests on GitHub:

https://github.com/samuelcouch/turnkit

Run tests:

bundle exec rake test

License

See the MIT License.