Skip to content

mostlydev/rails_trail

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RailsTrail

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.

Why this exists

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:

  1. Runtime — a model DSL and controller concern that enriches JSON responses with next_moves, resolved to real HTTP method + path pairs
  2. 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

Installation

Add to your Gemfile:

gem "rails_trail", path: "gems/rails_trail"   # local
# or
gem "rails_trail", github: "mostlydev/rails_trail"  # from GitHub

Quick start

1. Declare a trail on your model

RailsTrail 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
end

Now trade.next_moves returns only the actions valid from the trade's current state.

2. Add manual moves for things outside the state machine

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? }
end

The 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.

3. Enable response enrichment in your controller

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
end
  • trail: @trade — enriches a pre-serialized hash with next_moves from the given model
  • trail: false — skips enrichment (useful for list endpoints where you don't want the overhead)
  • No trail: option — auto-detects if the payload responds to next_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.

How it works

┌─────────────────────────────────────────────────────┐
│                    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 Describe layer

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:introspect

Configure 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/"
end

The 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.

Custom ID methods

If your routes use a non-standard ID parameter:

trail :status, id: :trade_id do
  expose :approve, :deny
end

Paths resolve with the correct value: /api/v1/trades/42/approve uses trade.trade_id, not trade.id.

API

Model

Method Returns Description
record.next_moves Array<Move> Actions valid from the record's current state

Move struct

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

Controller

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

Requirements

  • Ruby >= 3.1
  • Rails >= 7.0
  • AASM (optional — for auto-discovery of state machines)
  • ruby-openai (optional — only for rails_trail:describe task)

Running specs

cd gems/rails_trail
bundle install
bundle exec rspec

27 specs covering the navigable DSL, AASM adapter, route map, response enrichment, and describe layer.

License

MIT

Origin

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.

About

Rails gem that auto-discovers AASM state machines and emits next_moves in API responses

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages