SAM_COUCH
FIG_001 / TITLE_BLOCK

TurnKit: AI Orchestration for Ruby.

§ ABSTRACT TurnKit is a RubyGem for durable AI agents in Ruby, with agents, turns, system prompts, tools, skills, sub-agents, and Rails persistence.

§ CONTENTS 00/06

I wrote before about building an AI orchestration layer. The point of that piece was not “I learned to call a model.” It was that the model call stops being the interesting part very quickly.

The interesting part is the layer around it.

TurnKit is that layer packaged as a RubyGem. It gives Ruby apps a small runtime for durable agents: conversations, turns, messages, system prompts, tools, skills, sub-agents, budgets, and optional Rails persistence.

The real product behavior in an AI system lives in the orchestration layer.

Why TurnKit Exists

Most AI features start with a direct model call. Pass in messages, add a system prompt, render the response. That is fine for a prototype.

Then the feature grows. It needs memory. It needs tools. It needs to know which tool was called, with which arguments, and whether it failed. It needs budget limits so one bad loop does not burn money. It needs a prompt structure that can evolve without turning into a 400-line string. If you are building in Rails, it probably needs records you can inspect when something goes wrong.

That is what TurnKit is for.

It is not a chatbot UI framework. It is not a prompt collection. It is not trying to replace provider SDKs like RubyLLM. TurnKit sits one layer above the model client and answers the application questions:

  • Who is the agent?
  • What does it know?
  • Which tools can it use?
  • Which skills shape its behavior?
  • What did this turn see when it started?
  • What happened inside the loop?
  • Can I inspect it later?

Agents, Conversations, and Turns

TurnKit starts with three nouns.

An Agent is the behavior definition. It has a name, instructions, model, tools, skills, available skills, sub-agents, prompt settings, and budget settings.

A Conversation is the durable thread. It owns the messages and optional subject context.

A Turn is one unit of work. When you ask a conversation a question, TurnKit creates a turn, snapshots the conversation message sequence, runs the loop, records tool executions, and stores the final output.

ruby
require "turnkit"
TurnKit.default_model = "claude-sonnet-4-5"
agent = TurnKit::Agent.new(
name: "support_assistant",
instructions: "Answer customer questions clearly and briefly."
)
conversation = agent.conversation(subject: "Billing question")
turn = conversation.ask("Why was I charged twice this month?")
puts turn.status
puts turn.output_text

That looks small because the runtime is carrying the weight. ask appends the user message, creates a turn, builds the prompt, calls the model, executes tools if needed, appends tool results, repeats until done, and marks the turn completed or failed.

The Turn matters because production AI work is not always one request. One turn might include a model response, a tool call, a tool result, another model call, then a final answer. TurnKit gives that work a record.

Turns can also be created as pending work:

ruby
turn = conversation.ask("Review this contract for risky clauses.", async: true)
puts turn.id
puts turn.status
# Enqueue turn.id into your background job system.

That is the Rails-friendly shape I wanted: create durable work, run it inline or later, and keep enough state to inspect what happened.

The System Prompt Is the Real Sauce

The most important TurnKit feature is not a clever tool runner. It is the system prompt builder.

That sounds boring until you ship AI features. Then you realize the system prompt is where the behavior actually lives. It decides what the agent is, what rules it follows, which tools exist, which skills are loaded, what subject context matters, and what environment facts are available.

TurnKit treats that prompt as a structured object instead of one giant string. By default it renders these sections:

ruby
%i[
agent
instructions
behavior
loaded_skills
available_skills
tools
subject
environment
]

Each section is tagged. The agent section includes the name, description, and model. The behavior section contains the runtime rules: treat user messages as constraints, use the environment for dates, only call listed tools, read tool errors before retrying, and report outcomes honestly. The tools section renders each tool name, description, parameters, and whether it ends the turn. The subject section calls to_prompt on your domain object when it exists.

That structure is the difference between “we have a prompt” and “we have an operating manual.”

You can choose sections per agent:

ruby
agent = TurnKit::Agent.new(
name: "json_writer",
instructions: "Return concise JSON.",
prompt_sections: %i[agent instructions tools environment]
)

Or take full control with a prompt builder:

ruby
agent = TurnKit::Agent.new(
name: "strict_json_writer",
instructions: "Write release notes.",
system_prompt: ->(prompt) {
[
prompt.agent_section,
prompt.instructions_section,
prompt.tools_section,
"Return only valid JSON."
].compact.join("\n\n")
}
)

That API is doing more than formatting text. It gives the app control over the agent’s operating context. You can keep default safety behavior, remove noisy sections for narrow jobs, add a domain-specific policy, or replace the whole prompt when the workflow demands it.

The system prompt is the runtime contract between your app and the model.

Tools, Skills, and Sub-Agents

Tools are how the model asks your app to do work. A tool is not a callback the model runs. It is a model-facing contract: name, description, parameters, and Ruby execution.

ruby
class SaveReport < TurnKit::Tool
description "Save a finished report for the user."
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")}."
end
def call(title:, body:, context:)
{ report_id: "rep_1", title: title, body: body }
end
end

TurnKit advertises that schema to the model, but TurnKit owns execution. When the model requests a tool call, the runtime creates a tool execution, runs the Ruby code, stores the result or failure, appends a tool result message, and continues the turn. That matters because side effects should not disappear into provider SDK internals.

Skills solve a different problem. A skill is reusable instruction content, usually markdown, that can be loaded into the system prompt.

ruby
research = TurnKit::Skill.from_file(
"app/ai/skills/research.md",
description: "Use for source-backed research tasks."
)
agent = TurnKit::Agent.new(
name: "researcher",
skills: [research]
)

skills are loaded into the prompt. available_skills are listed as guidance the agent can use when relevant. That distinction keeps every turn from becoming a policy dump.

Sub-agents package whole behaviors. Add another TurnKit::Agent as a sub-agent and the parent sees it as a tool. The child turn shares the parent’s budget and records parent_turn_id plus parent_tool_execution_id, so delegation stays bounded and inspectable.

My rule of thumb:

  • Use tools for Ruby capabilities.
  • Use skills for reusable guidance.
  • Use sub-agents for delegated workflows.
  • Use prompt sections to decide what the model sees.

Rails Persistence When Durability Matters

TurnKit is a RubyGem first, and it defaults to an in-memory store. That is good for scripts, tests, and early experiments.

When you need Rails persistence, install it:

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

Then configure the store:

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

The generator creates tables for conversations, turns, messages, and tool executions. That gives the orchestration layer a real operational footprint. You can inspect a failed turn, see tool inputs and outputs, build admin review screens, or reconcile interrupted work.

Domain objects can also provide subject context with to_prompt:

ruby
class SupportTicket < ApplicationRecord
def to_prompt
<<~PROMPT
Support ticket ##{id}
Status: #{status}
Subject: #{subject}
#{description}
PROMPT
end
end
conversation = agent.conversation(subject: SupportTicket.find(params[:id]))

That is the right boundary. The model does not need your entire Active Record object. It needs the prompt representation your domain object chooses to expose.

TurnKit also includes TurnKit.reconcile_stale!, which marks old pending or running turns as stale based on heartbeat data. It is not glamorous. It is the kind of production hook that makes an AI feature supportable.

Owning the Runtime

Models will keep changing. Claude will get better at planning. GPT will get cheaper. Gemini will get faster. New providers will show up.

Great. Use them.

But do not put your product behavior inside the provider call. Put it in the runtime you own.

For TurnKit, that runtime is made of agents, prompt sections, skills, tools, sub-agents, turns, budgets, and persistence. The model is swappable. The orchestration compounds.

That was the lesson from building my own orchestration layer, and it is the reason I pulled this out into a gem. A chat completion is not a product. A durable orchestration flow can be.

If you are building AI features in Ruby and the plumbing is starting to pile up, try TurnKit. The code is on GitHub, and the gem is on RubyGems.

Install it, build a small agent, and make the loop yours.