Agent reference#
A dense, self-contained summary of Spider-Gazelle (action-controller ~> 8.3) for AI coding
agents and experienced developers. Paste it into an agent's context, or point the agent at
/llms.txt (an index of the site) or
/llms-full.txt (the whole site as Markdown).
Project conventions (template)#
src/app.cr entry point: CLI flags (-b -p -w -r -c --docs --file --mcp), server start
src/config.cr requires controllers, server, MCP; logging, handlers, sessions, MCP config
src/constants.cr NAME, VERSION, env vars (SG_ENV, SG_SERVER_PORT, SG_MCP_PATH ...)
src/controllers/ application.cr (abstract base) + one class per resource
spec/ spec_helper.cr requires src/config.cr; AC::SpecHelper.client
- Controllers inherit from an abstract
Application < AC::Base(the template calls itApp::Base). Shared filters, responders and error handlers go there. base "/path"sets the controller's path. It defaults to the snake case class name.- Build with
shards build, test withcrystal spec, and format withcrystal tool format.
Routes#
# Doc comment: first line is the OpenAPI summary; the whole comment is the MCP tool description
@[AC::Route::GET("/:id")] # GET POST PUT PATCH DELETE OPTIONS WebSocket
def show(id : Int64) : Model # args are params; the return type is the response schema
@[AC::Route::POST("/", body: :model, status_code: HTTP::Status::CREATED)]
def create(model : Model) : Model # body: names the argument parsed from the request body
@[AC::Route::GET("/", status: {A => HTTP::Status::OK, B => HTTP::Status::ACCEPTED})]
def index : A | B # status codes mapped by return type
@[AC::Route::GET("/raw", content_type: "text/plain")] # force the response type
@[AC::Route::GET("/:a", map: {value: :a})] # argument `value` reads param `a`
@[AC::Route::GET("/:id", config: {id: {base: 16}})] # converter options
@[AC::Route::GET("/:id", converters: {id: MyConverter})]
- Path segments:
:name(required),?:name(optional) and*:name(glob). - Params are taken from the path, then the query, then form data. Bodies use parsers
chosen by
Content-Type; responses use responders chosen byAccept. - A parameter is required unless it's nilable or has a default.
- Built-in conversions: numbers,
String,Char,Bool,Time,Enum,UUID, and unions.Boolistrueonly fortrue(any case); any other value isfalse. Arrays need a custom converter. @[AC::Param::Info(description:, example:, name:, header:, class:, config:)]sits on an argument. There's norequired:.- Multiple route annotations on one method are allowed. MCP exposes them as one tool using the first GET route.
Filters and errors#
@[AC::Route::Filter(:before_action, except: [:index])] # :before_action :around_action :after_action
def authenticate(@[AC::Param::Info(header: "Authorization")] auth : String) ...
@[AC::Route::Filter(:around_action)]
def transaction(&) Database.transaction { yield } end # around filters must yield
skip_action :authenticate, only: :show
@[AC::Route::Exception(NotFound, status_code: HTTP::Status::NOT_FOUND)]
def not_found(error) : ErrorBody # handlers are inherited
- Missing or invalid params raise
AC::Route::Param::MissingError/ValueError. Without an exception handler they become a500; the template maps them to422and400. -
rescue_from Klass, :methodalso works, but its handler mustrenderitself and it adds nothing to OpenAPI. Prefer the annotation. -
renderandheadshort-circuit an action or filter, e.g.head :unauthorized. - Filters can take typed params, which also appear in OpenAPI and MCP.
force_tls(aliasforce_ssl) redirects plain HTTP to HTTPS. Withoutonly:/except:it covers every route. Proxy headers decide the protocol, otherwise a connection to a portServerbound with TLS is HTTPS.
Sessions and cookies#
session["key"] = valuestoresString,Int64,Float64orBoolin an encrypted cookie; reads return that union, so use.as(Int64?)etc.cookiesholds the cookies the client sent. Set or delete cookies onresponse.cookies.
Responders and parsers#
abstract class Application < AC::Base
add_responder("application/yaml") { |io, result| result.to_yaml(io) }
default_responder "application/json"
add_parser("application/yaml") { |klass, body_io| klass.from_yaml(body_io.gets_to_end) }
end
OpenAPI#
ActionController::OpenAPI.generate_open_api_docs(title:, version:, **info)returns a NamedTuple; call.to_yamlon it.- It needs the source code and
crystal docs(comments are extracted at generation time), so generate at build time:./app --docs --file=openapi.yml. - Only annotated routes are documented, not the DSL
get "/" do.OPTIONSroutes aren't documented either.
MCP#
require "action-controller/mcp"
ActionController::MCPServer.mount(server, "/mcp") # in app.cr
ActionController::MCPServer.write_description("mcp.yml") # at build time (needs source)
@[AC::MCP(hide: true)] # exclude a route/controller
@[AC::MCP(root: true)] # available without opening the toolbox
@[AC::MCP(prompt: true)] # a prompt; must return String or Array(AC::PromptMessage)
- Controllers are toolboxes and each route method is one tool named
<toolbox>_<method>. The module namespace shared by all controllers is left out of the names. - WebSocket,
OPTIONSand DSL routes aren't exposed as tools. - Tool calls run the real route in-process, with the same filters and the same auth.
Authorization,CookieandX-API-Keyare forwarded. - Auth is optional:
auth_probe = "/users/current", plusresource_metadatafor OAuth.
Testing#
client = AC::SpecHelper.client # in-process HTTP client
client.get("/users/1", headers: HTTP::Headers{"Accept" => "application/json"})
controller = Users.spec_instance(HTTP::Request.new("GET", "/")) # unit test an instance
Common mistakes#
| Mistake | Fix |
|---|---|
| Developer notes in the doc comment above a route | they're published to OpenAPI and MCP; separate them with a blank line |
| No return type on a route | there's no response schema, and the compiler can't check what's returned |
@[AC::Param::Info(required: true)] |
doesn't exist; make the type non-nilable without a default |
| Generating docs from a deployed binary | generate at build time where the source and crystal are available |
| An exposed route is unsafe for agents | @[AC::MCP(hide: true)] and protect it with filters |
Expecting hide: true to block access |
it only hides the MCP tool; the HTTP route still works |
No handler for Param::MissingError / ValueError |
bad input becomes a 500; add @[AC::Route::Exception] handlers in the base class |
Setting a cookie with cookies["x"] = ... |
cookies is the request's; use response.cookies << HTTP::Cookie.new(...) |
session["id"] = 42 (an Int32) |
doesn't compile; store 42_i64 |
See also#
- Routing, Parameters, Responses, Filters and Errors for the full rules behind this summary
- OpenAPI and MCP
- Configuration for the template's CLI flags and environment variables