envoy_ai 0.0.1

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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +22 -0
  3. data/README.md +399 -0
  4. data/app/assets/stylesheets/envoy/console.css +371 -0
  5. data/app/controllers/envoy/application_controller.rb +18 -0
  6. data/app/controllers/envoy/conversations_controller.rb +36 -0
  7. data/app/controllers/envoy/messages_controller.rb +14 -0
  8. data/app/controllers/envoy/system_prompts_controller.rb +52 -0
  9. data/app/javascript/envoy/controllers/autoscroll_controller.js +46 -0
  10. data/app/javascript/envoy/controllers/composer_controller.js +30 -0
  11. data/app/jobs/envoy/application_job.rb +4 -0
  12. data/app/jobs/envoy/run_job.rb +65 -0
  13. data/app/models/envoy/application_record.rb +5 -0
  14. data/app/models/envoy/conversation.rb +20 -0
  15. data/app/models/envoy/message.rb +8 -0
  16. data/app/models/envoy/system_prompt.rb +24 -0
  17. data/app/models/envoy/system_prompt_version.rb +14 -0
  18. data/app/models/envoy/tool_call.rb +28 -0
  19. data/app/views/envoy/conversations/_composer_input.html.erb +4 -0
  20. data/app/views/envoy/conversations/index.html.erb +15 -0
  21. data/app/views/envoy/conversations/new.html.erb +19 -0
  22. data/app/views/envoy/conversations/show.html.erb +18 -0
  23. data/app/views/envoy/messages/_message.html.erb +7 -0
  24. data/app/views/envoy/messages/_streaming.html.erb +6 -0
  25. data/app/views/envoy/messages/_tool_call.html.erb +8 -0
  26. data/app/views/envoy/messages/create.turbo_stream.erb +25 -0
  27. data/app/views/envoy/system_prompts/_form.html.erb +14 -0
  28. data/app/views/envoy/system_prompts/edit.html.erb +2 -0
  29. data/app/views/envoy/system_prompts/index.html.erb +14 -0
  30. data/app/views/envoy/system_prompts/new.html.erb +2 -0
  31. data/app/views/envoy/system_prompts/show.html.erb +15 -0
  32. data/app/views/layouts/envoy/application.html.erb +20 -0
  33. data/config/routes.rb +7 -0
  34. data/db/migrate/20260710000001_create_envoy_conversations.rb +14 -0
  35. data/db/migrate/20260710000002_create_envoy_messages.rb +14 -0
  36. data/db/migrate/20260710000003_create_envoy_tool_calls.rb +14 -0
  37. data/db/migrate/20260711000001_create_envoy_system_prompts.rb +10 -0
  38. data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +13 -0
  39. data/db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb +6 -0
  40. data/lib/envoy/badges.rb +22 -0
  41. data/lib/envoy/compiled_tool.rb +43 -0
  42. data/lib/envoy/configuration.rb +21 -0
  43. data/lib/envoy/engine.rb +19 -0
  44. data/lib/envoy/errors.rb +6 -0
  45. data/lib/envoy/guard.rb +28 -0
  46. data/lib/envoy/llm.rb +24 -0
  47. data/lib/envoy/markdown.rb +16 -0
  48. data/lib/envoy/runner.rb +44 -0
  49. data/lib/envoy/testing/fake_llm.rb +41 -0
  50. data/lib/envoy/tool_definition.rb +38 -0
  51. data/lib/envoy/toolset.rb +72 -0
  52. data/lib/envoy/version.rb +3 -0
  53. data/lib/envoy.rb +45 -0
  54. data/lib/envoy_ai.rb +3 -0
  55. metadata +138 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 65af52593c07af82aa70d2e0f32876bebf053d6cd1cce31cdaf914fe76aac996
4
+ data.tar.gz: a0c236d33db995bdbd8e1bcbb6ff7eab428ac6aba5d4294b7b49273efbda16c6
5
+ SHA512:
6
+ metadata.gz: 98ff01d5c0134a18f4f39809b5e881418ea8e38d4117e84351ec4dd13e34db1a0b589f8cde715f6c07c147534f3af5fa96022254eceef09c8aec86d36bcaee8b
7
+ data.tar.gz: abebd4786665308b9988f3bed11333f34b7603803a57e7cdd98eed26b205e86572626882629467a52fb204664e3d6f0a9760456d367df286185fdf14414743a2
data/MIT-LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright Travis Petticrew
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,399 @@
1
+ # Envoy
2
+
3
+ Envoy is a mountable Rails engine for **governed, tool-calling agent chat**.
4
+ It wraps [RubyLLM](https://github.com/crmne/ruby_llm) (`~> 1.16`, legacy
5
+ `acts_as` mode) with:
6
+
7
+ - a small DSL for defining **toolsets** the host app owns (`app/envoy/**`),
8
+ - a **guard** that runs every tool call through the host's own authorization
9
+ and translates exceptions into model-legible error payloads,
10
+ - persistence for conversations/messages/tool calls, streamed over Turbo,
11
+ - a debug console (mounted wherever you like, e.g. `/envoy`) for driving and
12
+ inspecting chats.
13
+
14
+ ## 1. What Envoy is / when to use it
15
+
16
+ Use Envoy when you want to let an LLM take actions inside your app — not just
17
+ answer questions — while keeping every action inside your app's own
18
+ authorization model. Envoy is not an authorization framework and not a model
19
+ registry: it assumes the host already has an actor concept (a `User`, an API
20
+ token, whatever) and existing scoped-query methods on it (`actor.things`,
21
+ `actor.writable_things`, ...). Envoy's job is to:
22
+
23
+ - expose a small subset of those actions to the model as named, described,
24
+ parameterized tools (the toolset DSL),
25
+ - run every tool call through a **guard** that turns your app's own
26
+ exceptions (`ActiveRecord::RecordNotFound`, a raised `Envoy::Forbidden`,
27
+ `ArgumentError`) into a structured result the model can read and explain,
28
+ rather than a stack trace or a silent failure,
29
+ - persist the conversation (messages, tool calls, statuses) and stream the
30
+ reply over Turbo so a chat UI can show it live,
31
+ - gate mutation entirely: a conversation can be marked `read_only`, in which
32
+ case tools with `access :write` are never even sent to the model.
33
+
34
+ It has no dependency on any specific domain model. Reach for it when you're
35
+ building an in-app assistant that needs to call real methods on real records
36
+ — not for open-ended chat with no tool surface (plain RubyLLM is simpler for
37
+ that) and not as a substitute for your own policy/ACL layer (Envoy calls into
38
+ that layer, it doesn't replace it).
39
+
40
+ ## 2. Install
41
+
42
+ ```ruby
43
+ gem "envoy_ai"
44
+ ```
45
+
46
+ The gem is published as `envoy_ai` (`envoy` was taken on RubyGems), but the
47
+ namespace it defines is `Envoy`. `require "envoy_ai"` and `require "envoy"`
48
+ both load it; Bundler's default require of the gem name works with no
49
+ `require:` option.
50
+
51
+ ## 3. Configure
52
+
53
+ Envoy has two separate configuration surfaces: RubyLLM's own (API keys,
54
+ default model, logging) and Envoy's (which provider/models are allowed, how
55
+ to authenticate a request, how to resolve the acting record). Set both in one
56
+ initializer, `config/initializers/envoy.rb`:
57
+
58
+ ```ruby
59
+ RubyLLM.configure do |config|
60
+ config.use_new_acts_as = false # legacy acts_as: string model_id, no models table
61
+ config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"].to_s
62
+ config.default_model = "claude-sonnet-4-5"
63
+ config.logger = Rails.logger
64
+ end
65
+
66
+ Envoy.configure do |config|
67
+ # Which provider RubyLLM's `with_model` resolves against.
68
+ config.provider = :anthropic
69
+
70
+ # Fallback model id when a conversation doesn't pin its own.
71
+ config.default_model = "claude-sonnet-4-5"
72
+
73
+ # The actual allow-list for model ids. Because RubyLLM runs in legacy
74
+ # acts_as mode there's no models table to constrain this at the DB level —
75
+ # this array is the real gate a conversation's model_id is checked against.
76
+ config.available_models = [ "claude-sonnet-4-5", "claude-opus-4-1" ]
77
+
78
+ # Runs as a before_action in every Envoy controller. Raise/redirect to deny
79
+ # the request entirely (this is where your app's login check goes).
80
+ config.authenticate = ->(controller) { controller.authenticate_user! }
81
+
82
+ # Returns whatever polymorphic record chats/tool calls should be scoped to.
83
+ # This is the `actor:` that perform blocks receive (see section 7).
84
+ config.actor_resolver = ->(controller) { controller.current_user }
85
+
86
+ # Prepended to every conversation's instructions, ahead of the toolset
87
+ # description and the conversation's own system prompt. Optional — defaults
88
+ # to a generic "You are a helpful assistant..." string.
89
+ config.system_preamble = "You are the assistant embedded in Acme's app."
90
+
91
+ # Advanced/testing seam: a callable that builds the object driving a turn
92
+ # (`#run(content:, tools:, instructions:) { |kind, payload| ... }`). Defaults
93
+ # to `->(**opts) { Envoy::LLM.new(**opts) }`, which drives a real RubyLLM
94
+ # `acts_as_chat`. Tests swap this for `Envoy::Testing::FakeLLM` — see
95
+ # section 10.
96
+ # config.llm = ->(conversation:) { Envoy::LLM.new(conversation: conversation) }
97
+ end
98
+
99
+ # Host toolset definitions live under app/envoy/**. They register themselves
100
+ # via Envoy.define_toolset at load time (side effects, not class bodies), so
101
+ # Zeitwerk's autoloader never triggers them on its own -- force-load them
102
+ # here instead.
103
+ Rails.application.config.to_prepare do
104
+ Dir[Rails.root.join("app/envoy/**/*.rb")].each { |f| require_dependency f }
105
+ end
106
+ ```
107
+
108
+ `config.authenticate` and `config.actor_resolver` are the two hooks that tie
109
+ Envoy to the host's session. Everything a tool does should be reached through
110
+ methods on the resolved actor (see section 8).
111
+
112
+ Because toolset files register themselves via a top-level
113
+ `Envoy.define_toolset(...)` call rather than defining a class/module Zeitwerk
114
+ can map to a filename, `app/envoy` must also be excluded from autoloading
115
+ (`config/application.rb`):
116
+
117
+ ```ruby
118
+ Rails.autoloaders.main.ignore(Rails.root.join("app/envoy"))
119
+ ```
120
+
121
+ ## 4. Mount
122
+
123
+ ```ruby
124
+ # config/routes.rb
125
+ mount Envoy::Engine, at: "/envoy"
126
+ ```
127
+
128
+ This mounts the debug console (a conversation list, a chat view, and system
129
+ prompt CRUD) at whatever path you choose. Every request goes through
130
+ `Envoy::ApplicationController`'s `before_action`, which calls your
131
+ `authenticate` lambda first, so the console inherits your app's own login.
132
+ Conversations are scoped to `envoy_current_actor` (the `actor_resolver`
133
+ result) — a user can only see and post to their own conversations.
134
+
135
+ ## 5. Migrations
136
+
137
+ Envoy ships its own migrations (`envoy_conversations`, `envoy_messages`,
138
+ `envoy_tool_calls`, `envoy_system_prompts`, `envoy_system_prompt_versions`,
139
+ plus the FK linking a conversation to the system-prompt version it used):
140
+
141
+ ```bash
142
+ bin/rails envoy:install:migrations
143
+ bin/rails db:migrate
144
+ ```
145
+
146
+ ## 6. Assets
147
+
148
+ The engine ships two Stimulus controllers under its own `app/javascript`
149
+ (Enter-to-submit for the composer, autoscroll for the streaming transcript),
150
+ exposed to the host's asset pipeline by the engine itself (the
151
+ `envoy.assets` initializer adds `engines/envoy` — or wherever the gem is
152
+ installed — `app/javascript` to `config.assets.paths`). Pin and register them
153
+ in the host:
154
+
155
+ ```ruby
156
+ # config/importmap.rb
157
+ pin "envoy/controllers/composer_controller", to: "envoy/controllers/composer_controller.js"
158
+ pin "envoy/controllers/autoscroll_controller", to: "envoy/controllers/autoscroll_controller.js"
159
+ ```
160
+
161
+ ```js
162
+ // app/javascript/controllers/index.js
163
+ import EnvoyComposer from "envoy/controllers/composer_controller"
164
+ import EnvoyAutoscroll from "envoy/controllers/autoscroll_controller"
165
+ application.register("envoy-composer", EnvoyComposer)
166
+ application.register("envoy-autoscroll", EnvoyAutoscroll)
167
+ ```
168
+
169
+ If the host uses Tailwind with a content scan, add the engine's views to it
170
+ (they live outside `app/views`):
171
+
172
+ ```css
173
+ @source "../../../<path-to-envoy-gem>/app/views/**/*.erb";
174
+ ```
175
+
176
+ ## 7. Toolsets DSL
177
+
178
+ A toolset is a named group of tools the host defines with a small DSL:
179
+ `define_toolset`, `description`, `tool`, `access`, `param`, `perform`, and
180
+ `use` for composing other toolsets in. Toolset files live under the host's
181
+ `app/envoy/**` (one file per toolset is the convention) and self-register at
182
+ load — see the `to_prepare` force-load block in section 3. For example,
183
+ `app/envoy/documents.rb`:
184
+
185
+ ```ruby
186
+ Envoy.define_toolset :documents do
187
+ description "Search documents and create new ones. Documents are " \
188
+ "referenced by id."
189
+
190
+ tool :search_documents do
191
+ description "Search documents by title or body to get their id."
192
+ access :read
193
+ param :query, "Text to search for."
194
+ perform do |actor:, query:|
195
+ actor.visible_documents.where("title LIKE ?", "%#{query}%")
196
+ .map { |d| { id: d.id, title: d.title } }
197
+ end
198
+ end
199
+
200
+ tool :create_document do
201
+ description "Create a new document."
202
+ access :write
203
+ param :title, "The document's title."
204
+ param :body, "The document's body text."
205
+ perform do |actor:, title:, body:|
206
+ document = actor.writable_documents.create!(title: title, body: body) # <-- authorization
207
+ { created: true, document_id: document.id }
208
+ end
209
+ end
210
+ end
211
+ ```
212
+
213
+ `param` takes a name, a description, and optional `type:` (`:string` by
214
+ default) and `required:` (`true` by default) — these compile straight into
215
+ `RubyLLM::Tool` parameter declarations.
216
+
217
+ Toolsets can compose other toolsets with `use`, so a conversation-level
218
+ toolset can be assembled from smaller pieces without duplicating tool
219
+ definitions:
220
+
221
+ ```ruby
222
+ Envoy.define_toolset :assistant do
223
+ description "General assistant tools for this app."
224
+ use :documents, :calendar
225
+ end
226
+ ```
227
+
228
+ `use` resolves the named toolsets lazily (at `tools_for`/`description` time),
229
+ so definition order across files doesn't matter; a composition cycle (a
230
+ toolset that composes itself, directly or transitively) raises
231
+ `Envoy::ToolsetCycle`.
232
+
233
+ **`access :read` vs `access :write`**: a conversation with
234
+ `status: "read_only"` only receives tools whose `access` is `:read` —
235
+ write tools are filtered out of the merged tool list entirely, so the model
236
+ never even sees them. This is the governance lever for embedding a chat
237
+ somewhere that must never mutate state.
238
+
239
+ ## 8. Actor & authorization contract
240
+
241
+ `actor` is polymorphic — whatever your `actor_resolver` returns (a `User`, an
242
+ API client record, anything). It's the sole authorization contract: every
243
+ `perform` block receives it as a keyword argument alongside its declared
244
+ `param`s, and every lookup or mutation inside `perform` is expected to be
245
+ scoped through it (`actor.visible_documents.find(id)` for reads,
246
+ `actor.writable_documents.create!(...)` for writes) rather than through an
247
+ unscoped model class.
248
+
249
+ There's no separate policy object or ACL layer inside Envoy. If a scoped
250
+ lookup can't find the record, `find` raises `ActiveRecord::RecordNotFound`
251
+ and the Guard turns that into a `not_found` result. If you want a clearer,
252
+ intentional denial instead — the record exists but this actor may never touch
253
+ it — raise `Envoy::Forbidden` directly from inside `perform`:
254
+
255
+ ```ruby
256
+ perform do |actor:, document_id:|
257
+ document = Document.find(document_id)
258
+ raise Envoy::Forbidden, "Not your document." unless document.owner == actor
259
+ document.destroy!
260
+ { deleted: true }
261
+ end
262
+ ```
263
+
264
+ ## 9. Guard semantics
265
+
266
+ `Envoy::Guard.run` wraps every `perform` block and translates what it raises
267
+ or returns into a model-legible result — it never lets a tool call raise past
268
+ it:
269
+
270
+ | Raised / returned | `type` | tool-call `status` |
271
+ |----------------------------------------|--------------|---------------------|
272
+ | `Envoy::Forbidden` | `forbidden` | `denied` (message relayed to the model) |
273
+ | `ActiveRecord::RecordNotFound` | `not_found` | `failed` (generic message, not the exception text) |
274
+ | `ArgumentError` | `invalid` | `failed` (message relayed to the model) |
275
+ | any other `StandardError` | `error` | `failed` (message **not** leaked to the model) |
276
+ | block returns a Hash with an `:error` key | passthrough (whatever `:error`/other keys the block set) | `failed` |
277
+ | anything else | — | `succeeded` |
278
+
279
+ The model is told this convention in its instructions
280
+ (`Envoy::Runner::ERROR_CONVENTION`) so it can explain a failure to the user
281
+ instead of retrying blindly. Only `Envoy::Forbidden` and `ArgumentError`
282
+ messages (both host-authored, so safe by construction) and the fixed
283
+ `not_found` string reach the model; arbitrary `StandardError` messages are
284
+ swallowed so internals never leak through a tool result.
285
+
286
+ ## 10. Testing
287
+
288
+ Real RubyLLM calls hit the network, so the engine exposes one seam:
289
+ `Envoy.config.llm` — a callable that builds whatever object satisfies
290
+ `#run(content:, tools:, instructions:) { |kind, payload| ... }` for a given
291
+ conversation. In production this defaults to `Envoy::LLM.new(conversation:)`,
292
+ which drives `acts_as_chat` for real. Tests swap in
293
+ `Envoy::Testing::FakeLLM`, which persists the same `Envoy::Message` /
294
+ `Envoy::ToolCall` rows a real run would, but replays a fixed script of
295
+ directives instead of calling out to Anthropic:
296
+
297
+ ```ruby
298
+ Envoy.config.llm = ->(conversation:) do
299
+ fake = Envoy::Testing::FakeLLM.new(conversation: conversation)
300
+ fake.script = [
301
+ { tool: "find_athlete", args: { name: "Travis" } },
302
+ { tool: "log_score", args: { athlete_id: 1, scheduled_workout_id: 2,
303
+ fields: { time_seconds: 500 } } },
304
+ { text: "Logged your time of 8:20. Nice work!" },
305
+ ]
306
+ fake
307
+ end
308
+ ```
309
+
310
+ Each `{tool:, args:}` directive actually invokes the compiled tool (through
311
+ the real Guard, so denials/not-found/invalid branches are exercised), and
312
+ each `{text:}` directive appends to the assistant's message content (multiple
313
+ text directives accumulate, like real streaming deltas). Reset with
314
+ `Envoy.config.llm = nil` in teardown — don't nil the whole `Envoy.config`
315
+ object, or your `authenticate`/`actor_resolver` lambdas are lost for the rest
316
+ of the (single-process) test run.
317
+
318
+ This covers everything except the actual RubyLLM ↔ provider wire protocol —
319
+ that path needs a real API key and network access, so it isn't something
320
+ `FakeLLM` (or CI) can exercise.
321
+
322
+ ## 11. Development
323
+
324
+ ```bash
325
+ git clone git@github.com:ellym/envoy.git
326
+ cd envoy
327
+ bundle install
328
+ bundle exec bin/test
329
+ ```
330
+
331
+ `bin/test` is the gem's own runner (there's no root `bin/rails` here, since
332
+ this is a gem, not an app) — it runs the full Minitest suite against the
333
+ bundled `test/dummy` Rails app under `test/dummy`. Always invoke it with
334
+ `bundle exec` so it resolves against this gem's own `Gemfile.lock` rather
335
+ than whatever Rails/RubyLLM versions happen to be installed globally.
336
+
337
+ To run a single file or line, pass it straight through:
338
+
339
+ ```bash
340
+ bundle exec bin/test test/envoy/guard_test.rb
341
+ bundle exec bin/test test/envoy/guard_test.rb:12
342
+ ```
343
+
344
+ ## The debug console
345
+
346
+ Envoy ships a minimal chat UI mounted at whatever path the host chooses
347
+ (section 4). Posting a message (`MessagesController#create`) enqueues
348
+ `Envoy::RunJob`, which runs `Envoy::Runner` and broadcasts `:delta` (streamed
349
+ reply text) and `:tool` (a completed tool call) events over Turbo Streams to
350
+ the conversation's own broadcast channel. The view renders tool calls as
351
+ collapsible `<details>` chips showing the call name, an outcome badge
352
+ (succeeded/denied/failed), arguments, and the raw JSON result.
353
+
354
+ ## Real-gem notes worth recording
355
+
356
+ A few things about the installed RubyLLM (`~> 1.16`) that weren't obvious
357
+ from its docs and cost time to pin down:
358
+
359
+ - **Legacy `acts_as` mode** (`config.use_new_acts_as = false`): conversations
360
+ store a plain string `model_id` column; there is no separate `models`
361
+ table/migration to install. This is deliberate — Envoy doesn't manage a
362
+ model registry, the host just lists allowed model ids
363
+ (`Envoy.config.available_models`).
364
+ - **`with_model(id, provider:, assume_exists: true)`** — note the keyword is
365
+ `assume_exists:`, **not** `assume_model_exists:`. Passing the wrong keyword
366
+ silently gets ignored by Ruby's `**opts` splatting in some call shapes and
367
+ is easy to miss; verify against the installed gem
368
+ (`ruby_llm/active_record/chat_methods.rb`, `ruby_llm/chat.rb`) if upgrading.
369
+ - Every chat call passes `assume_exists: true` so a `model_id` that isn't in
370
+ RubyLLM's own registered-models list (e.g. a new Claude model id before
371
+ RubyLLM ships an update) still works — Envoy's own `available_models` list
372
+ is the actual gate, not RubyLLM's.
373
+ - **`commonmarker "~> 2.6"`** — pin this explicitly if your app also depends
374
+ on it. The 1.1.x line does not build against Ruby 4.0's native extension
375
+ toolchain.
376
+
377
+ ## Manual live smoke (not run in CI)
378
+
379
+ This validates the real RubyLLM/Anthropic path that `FakeLLM` stands in for
380
+ in tests. It requires a real `ANTHROPIC_API_KEY` and network access, so it's
381
+ a manual, non-CI gate — run it in a host app with Envoy mounted:
382
+
383
+ ```bash
384
+ export ANTHROPIC_API_KEY=sk-ant-...
385
+ bin/dev
386
+ ```
387
+
388
+ 1. Sign in as a web user.
389
+ 2. Visit the mounted path, start a new conversation with a toolset on
390
+ `claude-sonnet-4-5`.
391
+ 3. Ask it to call one of the write tools it exposes.
392
+ 4. Confirm:
393
+ - tool-call chips appear with a `succeeded` badge,
394
+ - the assistant's reply streams in (not just appears fully-formed),
395
+ - the underlying record was actually created/updated.
396
+
397
+ If this hasn't been run against the current code, treat RubyLLM-facing
398
+ changes (model ids, `with_model` kwargs, provider config) as unverified until
399
+ someone runs it with a key.