Tell your API consumers what they can do next.
RailsTrail adds a next_moves key to your Rails JSON responses — a list of the actions available right now given the current state of a record. Think of it like a hand of euchre: you don't need to know every rule of the game if someone just tells you which cards you're allowed to play.
{
"id": 42,
"status": "proposed",
"ticker": "NVDA",
"next_moves": [
{ "action": "approve", "method": "POST", "path": "/api/v1/trades/42/approve" },
{ "action": "deny", "method": "POST", "path": "/api/v1/trades/42/deny" },
{ "action": "confirm", "method": "POST", "path": "/api/v1/trades/42/confirm",
"description": "Trader confirms intent to proceed" }
]
}An AI agent reading this response doesn't need a 50-page service manual, a state machine diagram, or a Slack thread explaining the workflow. It can see exactly what's possible and how to do it — method, path, done.
RailsTrail was built for Clawdapus — a framework for running pods of AI agents that coordinate around shared services. In a Clawdapus pod, multiple agents operate a Rails trading API: proposing trades, approving them, executing them. The problem was obvious early: agents kept trying invalid actions, hallucinating endpoints, or getting stuck asking "what do I do now?"
The fix wasn't better prompts. It was better responses. If the API itself tells you what's legal from here, the agent doesn't need to maintain a mental model of the entire state machine. The state machine maintains itself.
RailsTrail has two layers:
- Runtime — a model DSL and controller concern that enriches JSON responses with
next_moves, resolved to real HTTP method + path pairs - Describe — a rake task that introspects your routes and state machines, then calls an LLM to generate a complete service manual as a markdown skill file
Add to your Gemfile:
gem "rails_trail", path: "gems/rails_trail" # local
# or
gem "rails_trail", github: "mostlydev/rails_trail" # from GitHubRailsTrail auto-discovers AASM state machines. You just tell it which events to surface:
class Trade < ApplicationRecord
include AASM
aasm column: :status do
state :proposed, initial: true
state :approved, :denied, :executing, :filled, :failed
event(:approve) { transitions from: :proposed, to: :approved }
event(:deny) { transitions from: :proposed, to: :denied }
event(:execute) { transitions from: :approved, to: :executing }
event(:fill) { transitions from: :executing, to: :filled }
event(:fail) { transitions from: :executing, to: :failed }
end
trail :status do
expose :approve, :deny, :execute, :fill, :fail
end
endNow trade.next_moves returns only the actions valid from the trade's current state.
Not everything is a state transition. A "confirm" action might set a timestamp without changing status. Declare it manually, with an optional guard:
trail :status do
expose :approve, :deny, :execute, :fill, :fail
from :proposed, can: [:confirm],
description: "Trader confirms intent to proceed",
if: -> { confirmed_at.blank? }
endThe if: lambda is evaluated on the record instance. When confirmed_at is already set, confirm disappears from next_moves. The agent never sees an action it can't take.
class Api::V1::TradesController < ApplicationController
trail_responses
def show
@trade = Trade.find(params[:id])
render json: trade_json(@trade), trail: @trade
end
def index
@trades = Trade.all
render json: @trades.map { |t| trade_json(t) }, trail: false
end
endtrail: @trade— enriches a pre-serialized hash withnext_movesfrom the given modeltrail: false— skips enrichment (useful for list endpoints where you don't want the overhead)- No
trail:option — auto-detects if the payload responds tonext_moves
Routes are resolved automatically from Rails.application.routes. If your routes look like POST /api/v1/trades/:id/approve, the move gets method: "POST" and path: "/api/v1/trades/42/approve" with the real ID interpolated.
┌─────────────────────────────────────────────────────┐
│ TrailDefinition │
│ │
│ Declared via `trail :status do ... end` on model │
│ Owns: state column, manual moves, expose list │
│ │
│ ┌──────────────┐ ┌───────────────────────┐ │
│ │ AasmAdapter │ │ Manual moves │ │
│ │ │ │ │ │
│ │ Reads AASM │ │ from :proposed, │ │
│ │ events, │ │ can: [:confirm], │ │
│ │ filters by │ │ if: -> { ... } │ │
│ │ current │ │ │ │
│ │ state + │ │ Evaluated at render │ │
│ │ expose list │ │ time on the instance │ │
│ └──────┬───────┘ └──────────┬────────────┘ │
│ │ dedup / merge │ │
│ └──────────┬────────────┘ │
│ ▼ │
│ [ Move structs ] │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ RouteMap │ │
│ │ Resolves action → method + path │ │
│ │ Auto-loaded from Rails routes │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
│
▼
┌──────────────┐
│ Responses │
│ concern │
│ │
│ Intercepts │
│ render json │
│ and merges │
│ next_moves │
└──────────────┘
AASM event normalization: If you have both event(:close) (from multiple states) and event(:close_from_resolved) (from one state), RailsTrail normalizes the _from_<state> suffix so the consumer sees one "close" action, not two.
Manual moves override AASM: If you declare a manual move with the same action name as an AASM event, the manual move wins (useful for adding descriptions or custom guards).
Guards vs. structure: AASM guards (:guard callbacks) are not evaluated at render time — they're enforced when the action is actually attempted. next_moves shows what's structurally possible from the current state. Manual if: lambdas are evaluated, because they represent business logic that determines whether an action should even be visible.
The second layer is a build-time tool: introspect your app and generate a markdown service manual that an AI agent can read as a skill file.
# Generate a full skill file via LLM
bin/rails rails_trail:describe
# Just dump the introspection data (no LLM call)
bin/rails rails_trail:introspectConfigure in an initializer:
# config/initializers/rails_trail.rb
RailsTrail.configure do |config|
config.service_name = "trading-api"
config.api_prefix = "/api/v1"
# For the describe task (requires ruby-openai gem)
config.ai_model = "claude-sonnet-4-20250514"
config.ai_api_key = ENV["ANTHROPIC_API_KEY"]
config.ai_base_url = "https://api.anthropic.com/v1/"
endThe introspector collects every route under your api_prefix, every AASM state machine on models with a trail declaration, and all manual moves. The prompt builder structures this into a prompt. The generator calls the LLM and writes the result as a markdown file with YAML frontmatter.
Pod-aware output: In a Clawdapus pod, the skill file lands at <pod_root>/services/<name>/docs/skills/<name>.md — automatically detected from CLAW_POD_ROOT env var or by walking up from Rails root looking for claw-pod.yml.
If your routes use a non-standard ID parameter:
trail :status, id: :trade_id do
expose :approve, :deny
endPaths resolve with the correct value: /api/v1/trades/42/approve uses trade.trade_id, not trade.id.
| Method | Returns | Description |
|---|---|---|
record.next_moves |
Array<Move> |
Actions valid from the record's current state |
| Field | Type | Description |
|---|---|---|
action |
String |
The action name (e.g., "approve") |
http_method |
String |
HTTP verb (e.g., "POST") — resolved from routes |
path |
String |
Full path with ID interpolated (e.g., "/api/v1/trades/42/approve") |
description |
String? |
Optional human/agent-readable description |
| Method | Effect |
|---|---|
trail_responses |
Class method — enables next_moves enrichment for all actions |
render json: data, trail: model |
Enriches data hash with model.next_moves |
render json: data, trail: false |
Skips enrichment |
- Ruby >= 3.1
- Rails >= 7.0
- AASM (optional — for auto-discovery of state machines)
- ruby-openai (optional — only for
rails_trail:describetask)
cd gems/rails_trail
bundle install
bundle exec rspec27 specs covering the navigable DSL, AASM adapter, route map, response enrichment, and describe layer.
MIT
Built at Tiverton House — a live trading pod where AI agents manage real portfolios. RailsTrail grew out of the realization that the cheapest way to make an agent competent is to stop expecting it to memorize your API and start having your API explain itself.