silas 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +211 -0
  3. data/DEPLOY.md +111 -0
  4. data/README.md +87 -250
  5. data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
  6. data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
  7. data/app/helpers/silas/inbox/trace_helper.rb +20 -5
  8. data/app/jobs/silas/channel_delivery_job.rb +15 -0
  9. data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
  10. data/app/models/silas/compaction.rb +32 -0
  11. data/app/models/silas/tool_invocation.rb +29 -3
  12. data/app/views/layouts/silas/inbox.html.erb +89 -30
  13. data/app/views/silas/inbox/invocations/_approval_card.html.erb +36 -13
  14. data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
  15. data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
  16. data/app/views/silas/inbox/sessions/index.html.erb +16 -15
  17. data/app/views/silas/inbox/sessions/show.html.erb +9 -0
  18. data/config/routes.rb +2 -0
  19. data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
  20. data/docs/agents.md +81 -0
  21. data/docs/budgets.md +67 -0
  22. data/docs/cancellation.md +41 -0
  23. data/docs/channels.md +290 -0
  24. data/docs/configuration.md +106 -0
  25. data/docs/connections.md +55 -0
  26. data/docs/conventions.md +161 -0
  27. data/docs/evals.md +95 -0
  28. data/docs/guarantees.md +76 -0
  29. data/docs/inbox-and-api.md +84 -0
  30. data/docs/memory.md +35 -0
  31. data/docs/sandbox.md +44 -0
  32. data/docs/tools.md +77 -0
  33. data/docs/tutorial.md +268 -0
  34. data/docs/vs-eve.md +93 -0
  35. data/docs/why-silas.md +87 -0
  36. data/lib/generators/silas/channel/channel_generator.rb +72 -0
  37. data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
  38. data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
  39. data/lib/generators/silas/install/install_generator.rb +11 -1
  40. data/lib/generators/silas/install/templates/claude_skill.md +136 -0
  41. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -1
  42. data/lib/silas/adapters/ruby_llm.rb +102 -46
  43. data/lib/silas/channel.rb +35 -0
  44. data/lib/silas/compactor.rb +178 -0
  45. data/lib/silas/configuration.rb +16 -0
  46. data/lib/silas/eval/dsl.rb +7 -2
  47. data/lib/silas/instrumentation.rb +7 -3
  48. data/lib/silas/ledger.rb +2 -2
  49. data/lib/silas/log_subscriber.rb +5 -0
  50. data/lib/silas/message_builder.rb +19 -0
  51. data/lib/silas/registry.rb +5 -2
  52. data/lib/silas/schedule.rb +44 -15
  53. data/lib/silas/slack.rb +8 -5
  54. data/lib/silas/step_runner.rb +5 -0
  55. data/lib/silas/tools/ask_question.rb +26 -0
  56. data/lib/silas/version.rb +1 -1
  57. data/lib/silas/webhook.rb +47 -0
  58. data/lib/silas.rb +3 -0
  59. metadata +30 -1
data/docs/vs-eve.md ADDED
@@ -0,0 +1,93 @@
1
+ # Silas vs eve
2
+
3
+ > Written **2026-07-26** against **eve 0.27.6** and Silas 0.5.x, from eve's
4
+ > published docs and source. Both projects move fast — if you're reading this
5
+ > much later, re-verify before deciding.
6
+
7
+ Silas and eve share the same organising idea: **an agent is a directory of
8
+ conventional files** — instructions, one file per tool, skills, schedules,
9
+ channels, connections — running on a durable loop. You can build the same
10
+ things with either: support desks, scheduled analysts, Slack copilots,
11
+ back-office automations. eve is TypeScript-native, built by Vercel on the AI
12
+ SDK, and fully self-hostable. Silas is Rails-native, a gem inside your
13
+ existing app.
14
+
15
+ So the honest first cut is simply your stack: **a TypeScript team should use
16
+ eve; a Rails team should use Silas.** The rest of this page is for people near
17
+ the boundary — and for the differences that go deeper than language.
18
+
19
+ ---
20
+
21
+ ## The comparison
22
+
23
+ | | Silas | eve (0.27.6) |
24
+ |---|---|---|
25
+ | **Shape** | A gem in your Rails app — your database, your job queue, your auth, one deploy. | A TypeScript service beside your app, with its own workflow store and deploy. |
26
+ | **Authoring** | A directory of plain files. | A directory of plain files — genuinely the same idea. |
27
+ | **Durable loop** | Survives `kill -9`, resumes from the last completed step. Chaos-gated every release: zero duplicate effects, byte-identical replay, SQLite + Postgres. | Workflow-engine replay; interrupted steps re-run. |
28
+ | **Tool-effect semantics** | **Exactly-once** for DB-recorded effects (`transactional!` — effect + ledger in one transaction). Default at-most-once: an ambiguous crash **parks for a human**. | **At-least-once, documented as such** — "make non-idempotent side effects like charges or emails idempotent, or gate them with approval." Dedup is the tool author's job. |
29
+ | **Human-in-the-loop** | Parks at zero compute; cleared from inbox, Slack, signed email, or API; parks expire; `ask_question` for the reverse direction. | `needsApproval` on tools. |
30
+ | **Operator surface** | A production inbox mounted in your app: live traces, approval cards, audit trail, cost accounting, web chat. | A dev TUI ("not a production chat UI or customer-facing dashboard"); production UIs are assembled from templates. |
31
+ | **Memory** | Shipped: approval-gated triples with provenance and supersession. | Deliberately out of scope. |
32
+ | **Ecosystem** | Ruby/Rails. | TypeScript + the AI SDK — a much larger ecosystem, with Vercel's distribution behind it. |
33
+ | **Channels & integrations** | Slack + email built in; a generator scaffolds any transport. | More first-party surfaces, growing quickly. |
34
+ | **Maturity** | Early (0.5.x); the durability contract is chaos-verified on every release. | Weeks old publicly; backed by a platform company, shipping at platform speed. |
35
+
36
+ ---
37
+
38
+ ## Where Silas goes further
39
+
40
+ **The transaction boundary.** When an agent's consequential action is a row in
41
+ your own database — a refund, a balance move, a ledger entry — Silas commits
42
+ the effect and the ledger's dedup record **in the same transaction**. Crash
43
+ before commit: both roll back. Crash after: the step is skipped on resume.
44
+ No idempotency keys, no reconciliation. This isn't about hosting — it's about
45
+ *whose transaction it is*: any runtime outside your application database,
46
+ self-hosted or not, keeps its "this step ran" record in its own store, and two
47
+ stores can't commit atomically. eve's docs draw the same conclusion from the
48
+ other side: make your side effects idempotent, or gate them. Silas is the
49
+ framework where you don't have to.
50
+
51
+ **Ambiguity parks.** At-least-once means a crash can re-fire a side effect.
52
+ Silas's default is at-most-once with **in-doubt → human**: it never
53
+ double-fires, and an ambiguous call waits for a person.
54
+
55
+ **The operator surface ships.** `mount Silas::Engine` and the inbox exists —
56
+ held/working/filed rail, live traces, approval cards, audit trail, cost, web
57
+ chat — behind your app's own auth.
58
+
59
+ **Memory ships.** Approval-gated, with provenance and supersession. eve
60
+ reasonably says "bring your own"; Silas ships the batteries.
61
+
62
+ ## Where eve goes further
63
+
64
+ - **The ecosystem.** TypeScript and the AI SDK are where most of the agent
65
+ world lives — more examples, more integrations, a bigger hiring pool, and
66
+ Vercel's reach.
67
+ - **Surface breadth and velocity.** More first-party channels and
68
+ integrations today, with a platform company's release cadence.
69
+ - **Sandboxing posture.** Container sandboxes are integral to eve's design.
70
+ Silas ships an interim Docker seam and reaches microVM-class isolation via
71
+ the [hermetic](https://github.com/danielstpaul/hermetic) gem.
72
+
73
+ ---
74
+
75
+ ## Choosing
76
+
77
+ **Pick Silas** if you're on Rails — your agent's tools are ordinary Ruby
78
+ against your own models, the inbox lives behind your auth, and effects your
79
+ app records in its own database get exactly-once semantics no external
80
+ runtime can match. Especially if your agents move money.
81
+
82
+ **Pick eve** if you're in TypeScript — it's an excellent framework with the
83
+ same authoring model, the ecosystem's momentum, and Vercel behind it.
84
+
85
+ Near the boundary (a Rails shop with a TS front-of-house, say): decide by
86
+ where your consequential side effects live. If they're rows in the Rails
87
+ app's database, that's where the agent belongs.
88
+
89
+ ---
90
+
91
+ *Claims about eve come from its docs and source at 0.27.6, quoted or
92
+ paraphrased in good faith; corrections welcome. Silas's numbers are
93
+ reproducible from `chaos_host/results/`.*
data/docs/why-silas.md ADDED
@@ -0,0 +1,87 @@
1
+ # Why Silas
2
+
3
+ ## Build agents the way you build Rails apps
4
+
5
+ Silas is for the same things every modern agent framework is for: a support
6
+ desk that actually resolves tickets, an analyst that posts the Monday digest,
7
+ an ops copilot in Slack, a back-office agent that chases invoices. An agent is
8
+ a directory of plain files — a persona, a data-only config, one file per tool
9
+ — and the framework supplies the durable loop, the scheduling, the channels,
10
+ the memory, and the operator surface. If you've looked at eve, the authoring
11
+ model will feel immediately familiar; that shape is the category's best idea,
12
+ and Silas embraces it.
13
+
14
+ What's different is *where it runs*. Silas isn't a second runtime you stand
15
+ beside your app — it's a gem inside the Rails app you already deploy:
16
+
17
+ - **Your tools are your app.** `Order.find_by!`, `refunds.create!`, your
18
+ service objects, your validations — no RPC layer between the agent and the
19
+ domain, because the agent lives where the domain lives.
20
+ - **Your auth is the agent's auth.** The operator inbox mounts as an engine
21
+ and hides behind whatever `current_user` already means. No second dashboard,
22
+ no second login, no second audit domain.
23
+ - **Your deploy is the agent's deploy.** One repo, one CI, one Kamal push.
24
+ The durable substrate — a database, Solid Queue, Active Job Continuations —
25
+ is already booted.
26
+
27
+ ## The guarantees go further
28
+
29
+ Every serious framework makes the loop durable. Silas draws the line a step
30
+ past that, and verifies it with a chaos harness that `kill -9`s live agents
31
+ hundreds of times per release (zero duplicate effects, byte-identical replay
32
+ — [guarantees](guarantees.md)):
33
+
34
+ - **Exactly-once tool effects.** A `transactional!` tool's database write and
35
+ the ledger's record of it commit in **one transaction**. A crash mid-refund
36
+ leaves exactly one refund row — never two, never zero — with no idempotency
37
+ key required. Only a framework *inside* your app can offer this: an external
38
+ runtime's ledger can never join your database transaction.
39
+ - **Ambiguity waits for a person.** The default mode is at-most-once: a crash
40
+ that makes "did it send?" unanswerable parks the call **in doubt** for a
41
+ human verdict instead of re-firing blind. Never double-pay; sometimes ask.
42
+ - **Holds cost nothing.** An approval, a question, or a budget breach parks
43
+ the turn at zero compute — the job exits, and clearing it resumes from
44
+ durable rows without re-calling the model.
45
+
46
+ If your agents only ever read, any durable loop will do. The moment one
47
+ touches money, inventory, or anything you'd hate to see happen twice, this is
48
+ the difference you'll feel.
49
+
50
+ ## Batteries included
51
+
52
+ A production operator inbox (live traces, approval cards, audit trail, cost
53
+ accounting), approval-gated memory with provenance, deterministic evals as a
54
+ deploy gate, Slack and email channels plus a generator for any transport, a
55
+ JSON API with SSE, per-turn budgets, replay-safe compaction — shipped in the
56
+ gem, not assembled from templates. And one command builds a working agent app
57
+ from nothing:
58
+
59
+ ```bash
60
+ rails new desk -m https://raw.githubusercontent.com/danielstpaul/silas/main/templates/desk.rb
61
+ ```
62
+
63
+ ## The honest notes
64
+
65
+ - **Silas is early** — 0.5.x, and the guarantees are proven by a reproducible
66
+ chaos harness rather than years of production traffic. The contract is
67
+ measured on every release, and it's stated precisely so you know exactly
68
+ what's promised.
69
+ - **Rails-only, on purpose.** If your team lives in TypeScript, eve is
70
+ excellent and closer to home — the [comparison](vs-eve.md) is honest about
71
+ that in both directions.
72
+ - **Exactly-once applies to effects in your database.** A raw external call
73
+ with no idempotency key of its own gets at-most-once + in-doubt parking —
74
+ strong, but not transactional. Model money as rows (your app probably
75
+ already does) and you get the full guarantee.
76
+ - **Inference goes to your configured provider.** Agent state stays in your
77
+ database; prompts still travel to the model you choose.
78
+ - **Untrusted code needs a real sandbox.** The built-in Docker seam is
79
+ interim; configure [hermetic](https://github.com/danielstpaul/hermetic) for
80
+ microVM-class isolation before running code you didn't write.
81
+
82
+ ## Try the claim
83
+
84
+ Run the template, ask for the £64 refund, clear it from the inbox — and while
85
+ the turn is resuming, `kill -9` the worker. Restart it. The turn completes
86
+ from its last checkpoint, and `Refund.count` is exactly 1. That's the pitch,
87
+ reproduced on your laptop in five minutes.
@@ -0,0 +1,72 @@
1
+ require "rails/generators"
2
+
3
+ module Silas
4
+ module Generators
5
+ # rails g silas:channel whatsapp
6
+ #
7
+ # A channel is two halves that must agree on one name, and hand-rolling
8
+ # them is where the mistakes live: inbound needs signature verification and
9
+ # a stable thread key, outbound needs the approval link to reach an
10
+ # operator. This scaffolds both, wired together, with the security
11
+ # decisions already made.
12
+ class ChannelGenerator < Rails::Generators::NamedBase
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Scaffold a Silas channel: outbound Channel class, inbound webhook controller, and its route."
16
+
17
+ # Channel identity is the filename (app/agent/channels/whatsapp.rb ->
18
+ # Agent::Channels::Whatsapp), and Registry#channels resolves it with
19
+ # `camelize`. A filename that isn't a snake_case identifier produces a
20
+ # constant Zeitwerk can't define, and the channel then fails at boot
21
+ # rather than here — so refuse it here, where the message is useful.
22
+ # (Rails' usual normalisation still applies first: `MsTeams` and
23
+ # `ms_teams` both land on ms_teams.)
24
+ def validate_name
25
+ return if file_name.match?(/\A[a-z_][a-z0-9_]*\z/)
26
+
27
+ raise Thor::Error, "#{file_name.inspect} is not a valid channel name — " \
28
+ "it must be lowercase words separated by underscores, " \
29
+ "starting with a letter (e.g. whatsapp, ms_teams)."
30
+ end
31
+
32
+ # Templates are .rb.tt (Rails' own convention): the .tt keeps ERB-bearing
33
+ # files out of the linter and off Zeitwerk's radar.
34
+ def create_channel
35
+ template "channel.rb.tt", "app/agent/channels/#{file_name}.rb"
36
+ end
37
+
38
+ # The webhook lives in the HOST app, not the engine: only the host knows
39
+ # the vendor's signature scheme and payload shape. The engine ships
40
+ # routes for Slack alone because it also ships Slack's verification.
41
+ def create_controller
42
+ template "controller.rb.tt", "app/controllers/agent/channels/#{file_name}_controller.rb"
43
+ end
44
+
45
+ def add_route
46
+ route %(post "/agent/channels/#{file_name}", to: "agent/channels/#{file_name}#create")
47
+ end
48
+
49
+ def show_next_steps
50
+ say <<~MSG, :green
51
+
52
+ Channel "#{file_name}" scaffolded:
53
+ app/agent/channels/#{file_name}.rb (outbound: answers + approvals)
54
+ app/controllers/agent/channels/#{file_name}_controller.rb (inbound: webhook)
55
+ config/routes.rb POST /agent/channels/#{file_name}
56
+
57
+ Next:
58
+ 1. Set the signing secret:
59
+ bin/rails credentials:edit -> silas:
60
+ #{file_name}:
61
+ signing_secret: ...
62
+ 2. Fill in the three TODOs — the vendor's signature scheme, how a
63
+ message maps to a thread key, and how to post a message back.
64
+ 3. Point the vendor's webhook at https://<your-host>/agent/channels/#{file_name}
65
+ 4. Restart: app/agent/ registers at boot.
66
+
67
+ Full contract and a worked example: docs/channels.md
68
+ MSG
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,48 @@
1
+ # <%= class_name %> channel — OUTBOUND delivery for sessions that started in
2
+ # <%= human_name %>. Inbound lives in
3
+ # app/controllers/agent/channels/<%= file_name %>_controller.rb.
4
+ #
5
+ # Identity is this filename: <%= file_name %>.rb -> Agent::Channels::<%= class_name %>,
6
+ # and the controller starts sessions with channel: "<%= file_name %>", which is how
7
+ # Silas finds this class again at delivery time. Delete this file to disable
8
+ # the channel.
9
+ #
10
+ # Both methods run OFF the durable loop, in ChannelDeliveryJob — so a transport
11
+ # outage retries the delivery without re-running a single tool or touching the
12
+ # ledger. Raising here is safe; it costs a retry, not a duplicate side effect.
13
+ class Agent::Channels::<%= class_name %> < Silas::Channel
14
+ # The agent's final answer for a turn.
15
+ def deliver_answer(session:, text:)
16
+ thread = session.metadata["<%= file_name %>"] || {}
17
+
18
+ # TODO: post `text` back to the conversation. `thread` is whatever the
19
+ # controller stashed in metadata (chat id, phone number, message id).
20
+ raise NotImplementedError,
21
+ "Agent::Channels::<%= class_name %>#deliver_answer: post #{text.inspect} to #{thread.inspect}"
22
+ end
23
+
24
+ # A tool has parked for human approval; the turn is holding zero compute
25
+ # until someone answers. `approval_url` mints a signed, expiring one-click
26
+ # link that works in any transport.
27
+ def deliver_approval(session:, invocation:)
28
+ approve = Silas::Channel.approval_url(invocation, :approve)
29
+ decline = Silas::Channel.approval_url(invocation, :decline)
30
+
31
+ # SECURITY: an approval must reach an OPERATOR, never whoever started the
32
+ # session — mailing the approve link to the customer who asked for the
33
+ # refund lets them approve it themselves. Deliver to your ops destination
34
+ # (a staff channel, an on-call number), and FAIL CLOSED when it isn't
35
+ # configured: no destination means no approval, not a silent one.
36
+ operator = Rails.application.credentials.dig(:silas, :<%= file_name %>, :operator)
37
+ if operator.blank?
38
+ Rails.logger.warn("[Silas] no credentials.silas.<%= file_name %>.operator — approval for " \
39
+ "invocation #{invocation.id} not delivered (won't send it to the requester).")
40
+ return
41
+ end
42
+
43
+ # TODO: send `approve` / `decline` to `operator`, with enough context to
44
+ # decide: invocation.tool_name and invocation.arguments.
45
+ raise NotImplementedError,
46
+ "Agent::Channels::<%= class_name %>#deliver_approval: send #{approve} / #{decline} to #{operator}"
47
+ end
48
+ end
@@ -0,0 +1,66 @@
1
+ # <%= class_name %> channel — INBOUND webhook. Outbound delivery lives in
2
+ # app/agent/channels/<%= file_name %>.rb.
3
+ #
4
+ # Inbound is pure trigger reuse: verify the request is genuine, derive a stable
5
+ # thread key, and hand it to Channel.dispatch — which starts a session for a new
6
+ # thread and continues the existing one for a reply. Nothing here touches the
7
+ # durable loop.
8
+ class Agent::Channels::<%= class_name %>Controller < ActionController::Base
9
+ # A webhook carries no browser session, so no CSRF token can exist. The
10
+ # signature below IS the authentication — do not remove one without the other.
11
+ skip_forgery_protection
12
+
13
+ before_action :verify_webhook!
14
+
15
+ def create
16
+ # TODO: derive a STABLE thread key — the same conversation must produce the
17
+ # same key every time, or every message starts a new session. Use the
18
+ # vendor's conversation/thread id, never a per-message id.
19
+ thread_key = params[:conversation_id].to_s
20
+ text = params[:text].to_s
21
+
22
+ return head(:ok) if thread_key.blank? || text.blank?
23
+
24
+ Agent::Channels::<%= class_name %>.dispatch(
25
+ thread_key: thread_key,
26
+ input: text,
27
+ # Whatever deliver_answer needs to reply. Stored on the session, so keep
28
+ # it small and free of secrets — it is visible in the inbox.
29
+ metadata: { "<%= file_name %>" => { "conversation_id" => thread_key } }
30
+ )
31
+ head :ok
32
+ rescue Silas::TurnInProgressError
33
+ # One active turn per session is an invariant, not a queue: a reply that
34
+ # arrives mid-turn is dropped. Tell the user, or buffer it, if that matters
35
+ # for your transport.
36
+ head :ok
37
+ end
38
+
39
+ private
40
+
41
+ # Rejects anything not genuinely signed. Silas::Webhook.verify_hmac handles
42
+ # the parts that are identical everywhere — constant-time comparison, the
43
+ # replay window, and failing closed when no secret is configured; you supply
44
+ # the vendor's shape.
45
+ #
46
+ # TODO: match your vendor's scheme. The three that vary:
47
+ # payload what they sign. Slack signs "v0:#{timestamp}:#{body}"; GitHub
48
+ # and Shopify sign the raw body. It must be request.raw_post,
49
+ # never re-serialized params — different bytes, different HMAC.
50
+ # prefix what they put before the digest ("v0=", "sha256=", or "").
51
+ # digest :hex (nearly everyone) or :base64 (Shopify, Twilio).
52
+ def verify_webhook!
53
+ ok = Silas::Webhook.verify_hmac(
54
+ secret: Rails.application.credentials.dig(:silas, :<%= file_name %>, :signing_secret),
55
+ signature: request.headers["X-Signature"],
56
+ payload: request.raw_post,
57
+ # Omitting a timestamp disables replay protection: a captured request can
58
+ # then be re-sent forever. Pass the vendor's timestamp header if it sends
59
+ # one, and treat its absence as a reason to check the vendor's docs.
60
+ timestamp: request.headers["X-Signature-Timestamp"],
61
+ prefix: "sha256="
62
+ )
63
+ head(:unauthorized) unless ok
64
+ ok
65
+ end
66
+ end
@@ -34,6 +34,15 @@ module Silas
34
34
  template "channel_email.rb", "app/agent/channels/email.rb"
35
35
  end
36
36
 
37
+ # A Claude Code / coding-agent skill: the app/agent/ conventions, the
38
+ # effect-mode and approval decision rules, and the ledger rules an agent
39
+ # must never violate — so a coding agent driving this app builds Silas
40
+ # agents correctly without the human learning the framework first.
41
+ # Delete the file if you don't use coding agents.
42
+ def create_claude_skill
43
+ template "claude_skill.md", ".claude/skills/silas/SKILL.md"
44
+ end
45
+
37
46
  def mount_engine
38
47
  route 'mount Silas::Engine => "/silas"'
39
48
  end
@@ -109,7 +118,8 @@ module Silas
109
118
  6. Schedules: edit app/agent/schedules/*, then `bin/rails silas:schedules`
110
119
  7. Channels (optional): set credentials.silas.slack.{signing_secret,bot_token}
111
120
  for Slack; route inbound mail to Silas::AgentMailbox for email.
112
- Delete app/agent/channels/{slack,email}.rb to disable.
121
+ Delete app/agent/channels/{slack,email}.rb to disable. Any other
122
+ transport: `bin/rails g silas:channel whatsapp`.
113
123
  8. Inbox + web chat: /silas/inbox, deny-by-default — uncomment
114
124
  config.inbox_auth in config/initializers/silas.rb to make it visible.
115
125
  9. Restart your server if it was running (app/agent/ registers at boot).
@@ -0,0 +1,136 @@
1
+ ---
2
+ name: silas
3
+ description: Build and modify AI agents in this Rails app with the Silas gem — tools, approvals, effect modes, schedules, channels, evals. Use whenever a task touches app/agent/, app/agents/, a Silas tool, an agent's instructions or limits, or agent durability/approval behaviour.
4
+ ---
5
+
6
+ # Building Silas agents
7
+
8
+ Silas runs durable AI agents inside this Rails app: every turn survives
9
+ `kill -9` and resumes from its last completed step, tool effects are
10
+ exactly-once, and risky calls park for a human at zero compute. The agent is
11
+ the `app/agent/` directory — you build agents by writing ordinary Ruby files
12
+ there, not by calling a framework API.
13
+
14
+ **Deep reference lives in the installed gem** — read it from the bundle when
15
+ you need more than this file:
16
+
17
+ ```sh
18
+ bundle show silas # then read README.md, docs/*.md, DEPLOY.md there
19
+ ```
20
+
21
+ ## The directory is the agent
22
+
23
+ ```
24
+ app/agent/
25
+ instructions.md # persona (ERB; snapshotted once per turn)
26
+ agent.yml # model + limits — data only, no code
27
+ tools/ # one file per tool; TOOL IDENTITY IS THE FILENAME
28
+ skills/ # markdown playbooks, loaded on demand (description: frontmatter)
29
+ schedules/ # cron.md (frontmatter) or .rb handlers -> silas:schedules compiles them
30
+ channels/ # transports (slack.rb, email.rb; generate more, see below)
31
+ app/agents/<name>/ # NAMED agents: same tree per agent, own tools/skills/schedules
32
+ ```
33
+
34
+ Files register at boot — restart the server after adding one.
35
+
36
+ ## Writing a tool (the part to get right)
37
+
38
+ ```ruby
39
+ # app/agent/tools/issue_refund.rb -> tool "issue_refund"
40
+ class Agent::Tools::IssueRefund < Silas::Tool
41
+ description "Refund part or all of an order."
42
+ param :amount_pence, :integer, desc: "Amount in pence"
43
+ approval ->(session:, input:) { input[:amount_pence] > 2000 ? :user_approval : :approved }
44
+ transactional!
45
+
46
+ def call(order_id:, amount_pence:, reason:)
47
+ Refund.create!(order_id:, amount_pence:, reason:)
48
+ end
49
+ end
50
+ ```
51
+
52
+ - **The keyword signature of `#call` IS the schema** the model sees. Keywords
53
+ only — never positional. `param` refines types/descriptions.
54
+ - **Return a Hash** (anything else is wrapped as `{"value" => ...}`). Raising
55
+ records a failed invocation the model sees — don't rescue-and-swallow.
56
+ - `session` is available inside `call` (the `Silas::Session` row).
57
+
58
+ ### Effect mode — decide by where the side effect lives
59
+
60
+ | The tool… | Declare | Why |
61
+ |---|---|---|
62
+ | writes this app's database | `transactional!` | effect + ledger row commit atomically: **exactly-once**, even through `kill -9` |
63
+ | calls an external API / sends anything | `at_most_once!` (the default) | a crash mid-call leaves it IN DOUBT → parks for a human verdict, never re-fires blind |
64
+ | only reads, safe to repeat | `idempotent!` | replays re-run it freely |
65
+
66
+ Never mark an external call `transactional!` — the ledger cannot roll back a
67
+ sent email. Model money as rows in this app's own DB whenever possible; that
68
+ is what upgrades the guarantee to exactly-once.
69
+
70
+ ### Approval — who holds the lever
71
+
72
+ `approval :never` (default) · `:always` · `:once` (one approval per identical
73
+ (tool, arguments) pair per session) · or a lambda returning `:user_approval`,
74
+ `:approved`, `:not_applicable`, or `{denied: "reason"}`. Approval parks the
75
+ turn at zero compute; a human settles it in the inbox (`/silas/inbox`), Slack,
76
+ email, or the JSON API. Gate anything that moves money or is hard to reverse.
77
+
78
+ The built-in `ask_question` tool is the reverse direction: the agent parks to
79
+ ask the operator something and resumes with their text as the tool result.
80
+
81
+ ## agent.yml
82
+
83
+ ```yaml
84
+ model: claude-sonnet-4-5 # must exist in ruby_llm's registry
85
+ description: One line, shown in rosters.
86
+ limits:
87
+ max_steps: 10 # model calls per turn
88
+ max_cost: 0.25 # dollars per turn
89
+ timeout: 300 # seconds of ACTIVE work — approval waits don't count
90
+ final_answer: # optional JSON schema -> Turn#answer_data
91
+ type: object
92
+ properties: { verdict: { type: string } }
93
+ ```
94
+
95
+ Budget breaches PARK the turn (a human can top up in the inbox); they don't
96
+ destroy work.
97
+
98
+ ## Rules that protect the durability contract
99
+
100
+ 1. **Solid Queue (or `:inline` for scripts) — never the Async adapter.** Async
101
+ double-executes continuation steps and silently voids exactly-once. Boot
102
+ raises in production if misconfigured.
103
+ 2. **Don't deploy tool/skill changes while turns are parked.** The definitions
104
+ digest fails a parked turn loudly on resume rather than running it against
105
+ a different agent (`NondeterminismError`). Settle parked turns first —
106
+ the same applies to toggling built-ins like `config.ask_question`.
107
+ 3. **Keep `Silas::DeadJobRescuerJob` in `config/recurring.yml`** — it is part
108
+ of the crash-recovery contract, and monitor worker liveness: the rescuer
109
+ can requeue work, it cannot conjure a consumer.
110
+ 4. **Never hand-delete `solid_queue_processes` rows** — a claimed job whose
111
+ process row is gone is invisible to every reaper.
112
+ 5. Tools must not spawn threads or run work outside `call` — everything the
113
+ ledger can't see is outside the guarantee.
114
+
115
+ ## Verify your work
116
+
117
+ ```sh
118
+ bin/rails silas:doctor # key, queue adapter, model, migrations, tools, rescuer
119
+ bin/rails silas:chat # talk to the agent from the terminal
120
+ bin/rails silas:eval # run test/agent_evals/*_eval.rb (deploy gate)
121
+ ```
122
+
123
+ Write an eval per behaviour you care about (`Silas::Eval.scenario` — see the
124
+ generated `test/agent_evals/example_eval.rb`). The inbox at `/silas/inbox` is
125
+ deny-by-default: wire `config.inbox_auth` in `config/initializers/silas.rb`
126
+ before expecting to see it.
127
+
128
+ ## More surface, same pattern
129
+
130
+ - **Another transport**: `bin/rails g silas:channel whatsapp` scaffolds the
131
+ signature-verifying webhook AND the outbound half (docs/channels.md in the gem).
132
+ - **A staff of agents**: `app/agents/<name>/` with its own tree;
133
+ `Silas.agent("name").start(input: ...)`; schedules in that directory tick
134
+ that agent.
135
+ - **Subagents / handoffs / memory / connections (MCP)**: see the gem README —
136
+ each is a directory or YAML file, never an orchestration graph.
@@ -11,9 +11,12 @@ RubyLLM.configure do |c|
11
11
  # hasn't migrated yet — see https://rubyllm.com/upgrading-to-1-7/
12
12
  c.use_new_acts_as = true if c.respond_to?(:use_new_acts_as=)
13
13
 
14
+ # A nil key is inert (Silas's boot check reports "no provider configured"),
15
+ # so this needs no ENV guard — and the acts_as opt-in above must run even
16
+ # on keyless boots, or the deprecation warning returns.
14
17
  c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
15
18
  # The per-request timeout (seconds; RubyLLM default 300). Under streaming
16
19
  # this is an idle-between-chunks timeout — the hang protection for a stuck
17
20
  # provider connection.
18
21
  # c.request_timeout = 120
19
- end if ENV["ANTHROPIC_API_KEY"].present?
22
+ end