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:

export ANTHROPIC_API_KEY=...
# or OPENAI_API_KEY=..., GEMINI_API_KEY=..., OPENROUTER_API_KEY=...

TurnKit uses RubyLLM by default. Choose the provider by choosing a RubyLLM model name:

TurnKit.default_model = "claude-sonnet-4-5" # Anthropic
# TurnKit.default_model = "gpt-4.1-mini"    # OpenAI
# TurnKit.default_model = "gemini-2.5-flash" # Gemini

You can also override the model per agent or per run.

To use a different model SDK, provide a client object that responds to chat:

class MyClient < TurnKit::Client
  def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
    # Call your provider here.
    TurnKit::Result.new(text: "provider response", model: model)
  end
end

TurnKit.client = MyClient.new

Ask an agent:

require "turnkit"

agent = TurnKit::Agent.new(
  name: "helper",
  instructions: "Answer briefly."
)

turn = agent.conversation.ask("Explain Ruby blocks in one sentence.")
puts turn.output_text

Usage

Create a conversation:

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

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

turn = conversation.run!
puts turn.output_text

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]
)

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

Add skills:

skill = TurnKit::Skill.from_file("skills/research.md")

agent = TurnKit::Agent.new(
  name: "researcher",
  skills: [skill]
)

List available skills:

research = TurnKit::Skill.from_file(
  "skills/research.md",
  description: "Use for source-backed research tasks."
)

agent = TurnKit::Agent.new(
  name: "researcher",
  instructions: "Prefer primary sources.",
  tools: [WebSearch, ReadWebPage],
  available_skills: [research]
)

Add subject context:

article = Article.find(1)
conversation = agent.conversation(subject: article)

Choose prompt sections:

agent = TurnKit::Agent.new(
  name: "writer",
  instructions: "Write plainly.",
  prompt_sections: %i[agent instructions tools environment]
)

Build a custom prompt:

agent = TurnKit::Agent.new(
  name: "custom",
  instructions: "Answer in JSON.",
  system_prompt: ->(prompt) {
    [
      prompt.agent_section,
      prompt.instructions_section,
      "Return only valid JSON."
    ].compact.join("\n\n")
  }
)

Use safe prompt data blocks for pipeline-specific prompts:

agent = TurnKit::Agent.new(
  name: "researcher",
  system_prompt: ->(prompt) {
    [
      prompt.section(:agent),
      prompt.section(:behavior),
      prompt.untrusted_section(
        :retrieval_context,
        ExternalSearch.results_for("turnkit"),
        label: "Retrieved external evidence."
      ),
      prompt.section(:tools),
      prompt.section(:environment)
    ].compact.join("\n\n")
  }
)

Choose a prompt mode:

TurnKit::Agent.new(name: "main", prompt_mode: :full)    # default sections
TurnKit::Agent.new(name: "worker", prompt_mode: :minimal) # agent, instructions, behavior, tools, environment
TurnKit::Agent.new(name: "raw", prompt_mode: :none)     # tiny TurnKit identity prompt

TurnKit automatically uses the minimal prompt mode for delegated sub-agent turns unless the child agent sets its own prompt_mode.

Inject live context on each turn:

TurnKit.context_contributors << ->(context) {
  TurnKit::LiveContextContribution.new(
    name: "account",
    content: AccountSummary.for(context.conversation.["account_id"]),
    trusted: false
  )
}

Live context and subject context are rendered below TurnKit::SystemPrompt::CACHE_BOUNDARY, so provider adapters can reuse the stable prefix in the future.

Add model-specific prompt guidance:

TurnKit.model_prompt_contributors[/claude/] = ->(context) {
  TurnKit::PromptContribution.new(
    stable_prefix: "Provider guidance for #{context.model}.",
    section_overrides: {
      behavior: "Be concise, tool-aware, and explicit about uncertainty."
    }
  )
}

Inspect prompt shape without storing raw prompt text:

prompt = TurnKit::SystemPrompt.new(agent: agent, turn: turn, conversation: conversation)
prompt.report
# => { "chars" => ..., "hash" => ..., "stable_chars" => ..., "dynamic_chars" => ... }

Delegate to sub-agents:

writer = TurnKit::Agent.new(
  name: "writer",
  description: "Draft concise copy."
)

editor = TurnKit::Agent.new(
  name: "editor",
  sub_agents: [writer]
)

turn = editor.conversation.ask("Ask the writer for three headlines.")
puts turn.output_text

Install Rails persistence:

bin/rails generate turnkit:install
bin/rails db:migrate

Configure Rails:

TurnKit.store = TurnKit::ActiveRecordStore.new
TurnKit.default_model = "claude-sonnet-4-5"
TurnKit.timeout = 300

Reconcile stale turns:

TurnKit.reconcile_stale!

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

Override defaults per agent:

agent = TurnKit::Agent.new(
  name: "analyst",
  model: "gpt-4.1-mini",
  max_iterations: 10,
  timeout: 60,
  cost_limit: 0.25
)

Override the model for a single conversation or turn:

conversation = agent.conversation(model: "claude-opus-4-1")
turn = conversation.run!(model: "gpt-4.1-mini")
Option Description
default_model Set the default RubyLLM model. The model name determines the provider.
client Set the model client. Defaults to TurnKit::Adapters::RubyLLM.new.
store Set the conversation store.
max_iterations Limit model calls per turn.
timeout Limit seconds per root turn.
max_depth Limit sub-agent nesting.
max_tool_executions Limit tool calls per root turn.
cost_limit Limit cost per root turn.
prompt_sections Set default system prompt sections.
prompt_behavior Override the default behavior section text.
prompt_data_max_chars Limit data-block content rendered into prompts.
context_contributors Add live per-turn prompt context blocks.
system_prompt_contributors Add global prompt prefix/suffix/section overrides.
model_prompt_contributors Add model-matched prompt contributions.

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.