insika 0.0.1 → 0.2.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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,374 @@
1
+ ---
2
+ title: Observability
3
+ parent: Operate & prove it
4
+ nav_order: 1
5
+ permalink: /observability/
6
+ ---
7
+
8
+ # Observability — OpenTelemetry (opt-in)
9
+
10
+ Insika already has an observability spine: the **event stream**. Every turn emits
11
+ structured events (`task_started`, `tool_call`/`tool_result`, `data_tool_call`,
12
+ `task_completed`/`task_failed`/`task_cancelled`), each stamped with
13
+ `task_id`/`session_id`/`seq`/`at`. The OpenTelemetry bridge is a **consumer** of
14
+ that stream: it translates the events into OTEL **spans** and **metrics**. The core
15
+ (Executor, tools) never gains an OTEL call — events observe, telemetry translates.
16
+ It's the same "events observe" principle the SSE surface already uses.
17
+
18
+ Not every event on the stream is a turn. Operator actions, refinement runs
19
+ (`:refinement_started`, `:refinement_report`, `:refinement_proposed`,
20
+ `:refinement_gated`, `:refinement_applied`, `:refinement_rejected` — see
21
+ [Refinement](REFINEMENT.md)),
22
+ authoring writes (`:golden_written`, `:agent_file_written`, …), queue bookkeeping
23
+ (`:turn_coalesced`, `:turn_steered`, `:turn_steer_released`, `:turn_interrupted` — see
24
+ [Agents](AGENTS.md#queue_mode--when-a-message-arrives-while-the-agent-is-busy)) and
25
+ channel delivery (`:channel_delivered` — see [Channels](CHANNELS.md))
26
+ travel the same stream and are **ignored** by the bridge: they open no span and
27
+ touch no instrument, because they are not part of a turn's latency or cost. Any
28
+ other subscriber still sees them.
29
+
30
+ They are worth subscribing to even so, because each is the ONLY record of
31
+ something that left no task of its own behind:
32
+
33
+ | Event | Data | What it answers |
34
+ |---|---|---|
35
+ | `:turn_coalesced` | `task_id`, `merged`, `arrivals[]` | the fragments a customer typed in a row arrived as separate messages, and when |
36
+ | `:turn_steered` | `task_id`, `count`, `total` | a message arrived mid-run and was appended to the turn in flight |
37
+ | `:turn_steer_released` | `task_id`, `released_as`, `count` | the run could not absorb it, so it became the turn `released_as` |
38
+ | `:turn_interrupted` | `task_id`, `replaced_by` | the turn was abandoned mid-run, and which turn replaced it |
39
+ | `:turn_stuck` | `task_id`, `agent`, `reason`, `message` | the agent declared it could not proceed (`signal_stuck`, WS5) — the deterministic signal a consumer escalates on |
40
+ | `:channel_delivered` | `channel`, `outbox_id`, `status`, `attempts`, `error` | the answer reached the platform (or did not) — the turn completing says nothing about that |
41
+ | `:delivery_failed` | `channel`, `outbox_id`, `status`, `attempts`, `error` | a delivery exhausted its bounded retries — the alert face of the row above (WS6) |
42
+ | `:budget_warning` | `agent`, `tenant`, `window`, `spent`, `cap` | a calendar budget crossed its threshold (`alert_at` or a soft cap) — once per window (WS2) |
43
+ | `:breaker_open` | `agent`, `ref`, `tenant` | the reliability circuit breaker tripped for a `(tenant, provider/model)` — further turns fail fast until the cooldown (WS3/WS6) |
44
+ | `:ttft` | `task_id`, `session_id`, `ttft_ms` | the provider's time-to-first-token on the streaming envelope — only under `INSIKA_TURN_TIMING`, once per turn (WS6) |
45
+
46
+ `delivery_failed` and `breaker_open` are the two the operator config is pointed at
47
+ (`alerts.webhook` on the profile): each only fires when something durable did
48
+ not land. `:ttft` is additive debug, absent unless `INSIKA_TURN_TIMING` is set.
49
+
50
+ `:channel_delivered` is the one worth alerting on: a turn can be `:task_completed`
51
+ and correct while the customer got nothing, because delivery is a separate,
52
+ retried, out-of-band step. `status: "failed"` means the reply is sitting in the
53
+ outbox and the customer is still waiting.
54
+
55
+ Counts, ids and times only — never message content. The text lives in the
56
+ transcript, which is the surface that is allowed to carry it.
57
+
58
+ The bridge speaks the standard the market already runs on: point any OTLP backend
59
+ at Insika and a real turn shows up as a full trace, next to counters and histograms
60
+ you can chart without touching a span.
61
+
62
+ **This page is a convention, not an integration.** Insika ships no dashboard, no
63
+ backend config, no vendor file. It ships a stable set of attribute and instrument
64
+ names, and the recipes below tell you what to chart against them — in whatever you
65
+ already run.
66
+
67
+ ## Contents
68
+
69
+ - [Turning it on](#turning-it-on-opt-in-parity-when-off)
70
+ - [Traces: the span reference](#traces-the-span-reference)
71
+ - [Metrics: the instrument reference](#metrics-the-instrument-reference)
72
+ - [Attribute reference](#attribute-reference)
73
+ - [Estimated cost](#estimated-cost)
74
+ - [Dashboards you can build](#dashboards-you-can-build)
75
+ - [Stability contract](#stability-contract)
76
+ - [Local: a standalone collector](#local-a-standalone-collector)
77
+ - [Production](#production)
78
+ - [Design (why it's safe)](#design-why-its-safe)
79
+
80
+ ## Turning it on (opt-in, parity when off)
81
+
82
+ Enabled by environment — no new code flag:
83
+
84
+ - `INSIKA_OTEL=1`, **or**
85
+ - the standard OTEL envs (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_EXPORTER`).
86
+
87
+ Destination, protocol and headers follow the **OTEL SDK's default config** (env):
88
+ point `OTEL_EXPORTER_OTLP_ENDPOINT` at your collector. `OTEL_SERVICE_NAME` names
89
+ the service (default `insika`).
90
+
91
+ Metrics ride the same switch and the same standard env — no Insika-specific toggle
92
+ is invented for them. `OTEL_METRICS_EXPORTER=none` turns metrics off while traces
93
+ stay on; `OTEL_METRIC_EXPORT_INTERVAL` (ms) sets the export period. If the metrics
94
+ SDK is not in the bundle at all, the bridge degrades to traces only rather than
95
+ failing to boot.
96
+
97
+ **Off (the default):** `Insika::Telemetry.setup` returns `nil`, the OTEL gems are
98
+ **never loaded** (lazy `require`, like the LLM client), and nothing is
99
+ instrumented — zero overhead. This is enforced by a test
100
+ (`spec/insika/load_guard_spec.rb`: "require insika does not load
101
+ OpenTelemetry").
102
+
103
+ ## Traces: the span reference
104
+
105
+ One span per **turn**, with **child spans** per tool, correlated by `task_id`.
106
+ Latency is the span duration, reconstructed from the events' real `at` timestamps.
107
+
108
+ | Span | Emitted for | Parent |
109
+ |------|-------------|--------|
110
+ | `insika.turn` | one per turn, opened on `task_started`, closed on the terminal event | root |
111
+ | `insika.tool` | one per tool call, `tool_call` → `tool_result` (FIFO-correlated) | `insika.turn` |
112
+ | `insika.data_tool` | one per data-tool call — point-in-time (the engine emits a single event) | `insika.turn` |
113
+
114
+ A turn that ends with `task_failed` also carries the OTEL **error status**, with
115
+ the failure message.
116
+
117
+ ## Metrics: the instrument reference
118
+
119
+ The same events feed instruments, so volume, latency, tokens and cost are chartable
120
+ **without aggregating spans** — which not every backend does, and none does cheaply
121
+ at retention. Metrics are recorded on the *terminal* event, so every point already
122
+ knows its outcome.
123
+
124
+ | Instrument | Type | Unit | Recorded when |
125
+ |------------|------|------|----------------|
126
+ | `insika.turns` | counter | `{turn}` | a turn reaches a terminal state |
127
+ | `insika.turn.duration` | histogram | `s` | same, when both timestamps are known |
128
+ | `insika.tokens` | counter | `{token}` | the turn reported usage |
129
+ | `insika.cost` | counter | `{USD}` | the turn's model is priced (see below) |
130
+ | `insika.tool.calls` | counter | `{call}` | a tool call completes |
131
+ | `insika.tool.duration` | histogram | `s` | a `tool_call`/`tool_result` pair completes |
132
+
133
+ `insika.tool.duration` is deliberately **not** recorded for data-tools: those are a
134
+ single point-in-time event, so there is no measured duration to report. A tool left
135
+ open by a mid-turn failure is not counted as a completed call either — its span is
136
+ closed, but a failed call must not inflate the success histogram.
137
+
138
+ ## Attribute reference
139
+
140
+ The same names are used on spans and on metrics. **Metrics carry a deliberate
141
+ low-cardinality subset** — `task_id` and `session_id` are span-only, because a
142
+ metric attribute with per-turn cardinality is how you destroy a metrics backend.
143
+
144
+ | Attribute | Type | On spans | On metrics | Meaning |
145
+ |-----------|------|----------|------------|---------|
146
+ | `insika.task_id` | string | `turn` | — | the turn's id (correlates with `/v1/responses`, the Studio, the task store) |
147
+ | `insika.session_id` | string | `turn` | — | the chat this turn belongs to |
148
+ | `insika.agent` | string | `turn` | all | the agent profile that ran the turn |
149
+ | `insika.tenant` | string | `turn` | all | the **operator-set** tenant (see below); absent when the command declared none |
150
+ | `insika.command` | string | `turn` | all | command type (`send_message`, `trigger_workflow`, …) |
151
+ | `insika.status` | string | `turn` | turn instruments | `ok` / `error` / `cancelled` / `abandoned` |
152
+ | `insika.model` | string | `turn` | all except tool | model id the provider reported |
153
+ | `insika.model_source` | string | `turn` | — | which config layer resolved the model (chat / agent / model / global) |
154
+ | `insika.tokens.input` | int | `turn` | — | input tokens (**includes** the cached ones) |
155
+ | `insika.tokens.output` | int | `turn` | — | output tokens |
156
+ | `insika.tokens.total` | int | `turn` | — | input + output |
157
+ | `insika.tokens.cached` | int | `turn` | — | cache **reads**, a subset of `tokens.input` |
158
+ | `insika.tokens.cache_creation` | int | `turn` | — | cache **writes**, *not* inside `tokens.input` |
159
+ | `insika.token.type` | string | — | `insika.tokens` | `input` / `output` / `cached` / `cache_creation` |
160
+ | `insika.cost.usd` | double | `turn` | — | estimated cost of the turn (span attribute; the metric is the `insika.cost` counter) |
161
+ | `insika.tool` | string | `tool`, `data_tool` | tool instruments | tool name |
162
+ | `insika.tool.kind` | string | — | tool instruments | `tool` / `data_tool` |
163
+ | `insika.http.status` | int | `data_tool` | `insika.tool.calls` | HTTP status the data-tool got back |
164
+
165
+ ### `insika.tenant`
166
+
167
+ The tenant is the **explicit** tenant of the Command (`Command.build(…, tenant:)`)
168
+ — the one grouping label an operator sets deliberately, typically the merchant,
169
+ workspace or customer the turn belongs to. It is **not** the memory scope, which
170
+ falls back to the chat id: putting per-chat cardinality on a metric attribute is
171
+ exactly the failure this distinction avoids. A command with no tenant emits **no
172
+ attribute at all**, rather than a null or an `"unknown"` bucket.
173
+
174
+ ### Cardinality budget
175
+
176
+ Metric cardinality is roughly `agents × tenants × models × statuses × commands`
177
+ (and `agents × tenants × tools` for the tool instruments). All of those are
178
+ operator-controlled and closed-ish sets. Keep them that way: if you find yourself
179
+ wanting per-chat or per-user metrics, that is a **trace** query, and the span
180
+ attributes are there for it.
181
+
182
+ ## Estimated cost
183
+
184
+ Insika ships **no prices**. They change weekly, differ per contract and per region,
185
+ and a stale table inside the engine would be worse than no number. You declare the
186
+ rates; Insika multiplies. Unset, no cost is reported anywhere.
187
+
188
+ Set `INSIKA_MODEL_PRICING` to a JSON object of model id → rates in **USD per
189
+ million tokens**:
190
+
191
+ ```bash
192
+ INSIKA_MODEL_PRICING='{
193
+ "deepseek-v4-flash": {"input": 0.27, "output": 1.10, "cached_input": 0.07},
194
+ "claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cached_input": 0.30, "cache_write": 3.75}
195
+ }'
196
+ ```
197
+
198
+ - A key matches the model id the provider reports, **with or without** the
199
+ `provider/` prefix — `deepseek/deepseek-v4-flash` and `deepseek-v4-flash` both hit the
200
+ same entry.
201
+ - `input` / `output` are required (one of them is enough for the entry to load).
202
+ - `cached_input`, when given, bills cache **reads** at that rate and subtracts them
203
+ from the fresh input. Omit it and cached tokens simply stay at the input rate.
204
+ - `cache_write`, when given, bills cache **creation** tokens at that rate. Omit it
205
+ and they are billed at the input rate.
206
+ - An **unpriced model reports nothing** — no attribute, no metric point. A missing
207
+ price is not a zero cost, and a dashboard should show the gap.
208
+ - A malformed table degrades to "no cost". Telemetry config can never stop a boot.
209
+
210
+ The number is an **estimate for trend and attribution**, not a bill. Reconcile
211
+ against your provider's invoice, never the other way round.
212
+
213
+ ## Dashboards you can build
214
+
215
+ Written against the convention, not against a product. Each recipe is
216
+ *instrument → aggregation → group-by*; the PromQL line is one illustration of the
217
+ shape, and translates directly to whatever query language your backend uses.
218
+
219
+ > **Names get normalized.** Prometheus-style backends rewrite OTLP names: dots
220
+ > become underscores, counters gain `_total`, and a real (non-annotation) unit is
221
+ > appended — so `insika.turn.duration` in `s` becomes
222
+ > `insika_turn_duration_seconds`. Annotation units like `{turn}` are dropped.
223
+ > Check your exporter's mapping; the convention below is the OTLP spelling.
224
+
225
+ **Turn volume by agent**
226
+ `insika.turns`, rate, grouped by `insika.agent`.
227
+ ```promql
228
+ sum by (insika_agent) (rate(insika_turns_total[5m]))
229
+ ```
230
+
231
+ **Error rate by agent** — the single most useful panel.
232
+ `insika.turns` filtered to `insika.status="error"`, over the same counter unfiltered.
233
+ ```promql
234
+ sum by (insika_agent) (rate(insika_turns_total{insika_status="error"}[5m]))
235
+ / sum by (insika_agent) (rate(insika_turns_total[5m]))
236
+ ```
237
+
238
+ **Latency p95 by agent**
239
+ `insika.turn.duration`, 95th percentile, grouped by `insika.agent`.
240
+ ```promql
241
+ histogram_quantile(0.95, sum by (le, insika_agent) (rate(insika_turn_duration_seconds_bucket[5m])))
242
+ ```
243
+ Turn latency is dominated by the provider, not by the engine — see
244
+ [BENCHMARK.md](BENCHMARK.md) for the engine's own overhead, which is sub-millisecond
245
+ and will not show up here.
246
+
247
+ **Token burn by model**
248
+ `insika.tokens`, rate, grouped by `insika.model` and `insika.token.type`. Splitting
249
+ by type is what makes the cache visible: a healthy prompt cache shows `cached`
250
+ climbing while `input` stays flat.
251
+
252
+ **Cache hit ratio**
253
+ `insika.tokens` filtered to `insika.token.type="cached"` over the same counter
254
+ filtered to `input`. This is the number that moves your bill.
255
+
256
+ **Spend per tenant**
257
+ `insika.cost`, rate (or `increase` over a billing window), grouped by
258
+ `insika.tenant`. Swap the group-by for `insika.agent` to get spend per agent — the
259
+ same attribution the per-agent token ceilings in [SECURITY.md](SECURITY.md) act on.
260
+
261
+ **Tool reliability**
262
+ `insika.tool.calls`, rate, grouped by `insika.tool`; for data-tools add
263
+ `insika.http.status` to see which upstream is failing. Pair it with
264
+ `insika.tool.duration` p95 grouped by `insika.tool` to find the slow one.
265
+
266
+ **Workflows vs chats**
267
+ Any turn instrument grouped by `insika.command` — `trigger_workflow` and
268
+ `send_message` have very different latency and token profiles, and mixing them in
269
+ one average hides both.
270
+
271
+ **From a chart to the actual conversation**
272
+ Every panel above is grouped by attributes that also exist on the `insika.turn`
273
+ span. Filter your trace view by the same `insika.agent` / `insika.tenant` /
274
+ `insika.status`, open a trace, and `insika.task_id` and `insika.session_id` take you
275
+ to the exact turn in the Studio.
276
+
277
+ ## Stability contract
278
+
279
+ These names are an interface. Dashboards, alerts and recording rules are built on
280
+ top of them, and renaming one breaks all of them silently — a chart does not error,
281
+ it just goes flat.
282
+
283
+ - Instrument names, units and attribute keys on this page are **stable**. They
284
+ change only in a major version, and only with a note in `CHANGELOG.md`.
285
+ - Growth is **additive**: new attributes and new instruments may appear in a minor
286
+ version. Do not write a query that assumes a fixed attribute set.
287
+ - An attribute whose value is unknown is **omitted**, never emitted as `null`,
288
+ `""` or `"unknown"`. Handle absence in your queries rather than expecting a
289
+ placeholder bucket.
290
+ - Everything under `insika.*` is ours. Standard OTEL resource attributes
291
+ (`service.name`, and so on) come from the SDK and follow OTEL's own conventions.
292
+
293
+ ## Local: a standalone collector
294
+
295
+ To see traces on your machine, run a standalone OTLP collector — the quickest is
296
+ Jaeger all-in-one (OTLP on `4318`, UI on `16686`):
297
+
298
+ ```bash
299
+ docker run --rm -d --name jaeger \
300
+ -p 16686:16686 -p 4318:4318 \
301
+ jaegertracing/all-in-one:latest
302
+ ```
303
+
304
+ Then boot the engine with OTEL enabled, pointed at the collector:
305
+
306
+ ```bash
307
+ INSIKA_OTEL=1 OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
308
+ DEEPSEEK_API_KEY=sk-... bundle exec ruby scripts/serve_real.rb
309
+ ```
310
+
311
+ `serve_real` prints `OTEL → on (traces + metrics to OTLP)` on boot. Chat at
312
+ `http://localhost:9292/studio` and open the traces at
313
+ **`http://localhost:16686`** (service `insika`): one `insika.turn` span per turn,
314
+ with `insika.tool`/`insika.data_tool` children and the attributes above. Stop the
315
+ collector with `docker rm -f jaeger`.
316
+
317
+ Jaeger stores traces only — to see the metrics too, point the same endpoint at an
318
+ OTLP collector that fans out to a metrics store, or add `OTEL_METRICS_EXPORTER=none`
319
+ to silence the metrics exporter's retries while you work on traces.
320
+
321
+ > We don't version a `docker-compose` file — the collector is standalone, a
322
+ > local run-it decision. The one-liner above is the whole recipe.
323
+
324
+ ## Production
325
+
326
+ Set the OTEL envs on the deployment and every worker exports to your collector:
327
+
328
+ ```bash
329
+ INSIKA_OTEL=1
330
+ OTEL_EXPORTER_OTLP_ENDPOINT=https://<your-collector>:4318
331
+ OTEL_SERVICE_NAME=insika # optional; names the service
332
+ INSIKA_MODEL_PRICING='{...}' # optional; unlocks the cost attribute + counter
333
+ # plus any standard OTEL_EXPORTER_OTLP_HEADERS your backend needs
334
+ ```
335
+
336
+ The bridge is attached once, at boot, on the serving reactor — in `config.ru`,
337
+ `scripts/serve_real.rb` and the DSL's `serve` (so `Insika.agent { … }.serve` exports
338
+ too). It subscribes to the event stream in a long-lived fiber, sibling to serving.
339
+
340
+ ## Design (why it's safe)
341
+
342
+ - **`Telemetry::Recorder`** — pure: translates event → spans and instruments against
343
+ a duck-typed `tracer` (`start_span`/`set_attribute`/`record_error`/`finish`) and
344
+ `meter` (`create_counter`/`create_histogram`). It does not reference
345
+ `OpenTelemetry::` and is unit-tested with fakes. `record` **never raises**
346
+ (telemetry must not bring down a turn); orphan events are ignored; tool
347
+ correlation is FIFO; abandoned turns (e.g. `kill -9`, no terminal event) are
348
+ bounded and evicted (`MAX_OPEN`) and counted as `status="abandoned"`.
349
+ - **`Telemetry::Pricing`** — pure: an operator-declared rates table times the turn's
350
+ usage. No network, no bundled price list, no exception path.
351
+ - **`Telemetry.setup`** — the gem boundary: lazy `require` + configures the SDK +
352
+ returns a `Recorder` wired to the real tracer and meter. Covered by a smoke test
353
+ against the **real OTEL SDK** with in-memory exporters (span name/attributes/
354
+ hierarchy/error status; instrument names/units/attributes).
355
+ - **No reader means no meter.** If every metric reader is disabled, `setup` injects
356
+ no meter at all. Recording into a provider nothing drains would accumulate a point
357
+ per attribute set forever — a slow leak dressed as telemetry.
358
+ - **`Telemetry.attach`** — subscribes the recorder to the event stream inside the
359
+ reactor. No-op when the recorder is `nil` (disabled path), before touching the
360
+ reactor.
361
+
362
+ ## Backpressure
363
+
364
+ Each event-stream subscription is capped at 1000 buffered events; a telemetry
365
+ consumer that fell far behind would have its subscription closed (telemetry stops,
366
+ the turn does not). Span and instrument operations are cheap, so there's ample
367
+ headroom.
368
+
369
+ ---
370
+
371
+ *Packaging note.* Today the bridge lives in the Insika repo as an opt-in core
372
+ module — no separate install, no separate versioning. When Insika extracts its
373
+ subsystems into gems it becomes `insika-otel`; because the bridge is already a
374
+ pure event-stream consumer with a single gem boundary, that cut lands clean.
data/docs/PLUGINS.md ADDED
@@ -0,0 +1,211 @@
1
+ ---
2
+ title: Plugins
3
+ parent: Build an agent
4
+ nav_order: 7
5
+ permalink: /plugins/
6
+ ---
7
+
8
+ # Plugins
9
+
10
+ Insika has **two tiers of extension**, and picking the right one is almost always
11
+ obvious once you ask a single question: *does this need Ruby to run in-process?*
12
+
13
+ | | **Tier 1 — data** | **Tier 2 — code** |
14
+ |---|---|---|
15
+ | What you write | JSON/YAML config | a Ruby gem (or a directory) |
16
+ | Ships as | a row in SQLite | a `.rb` file loaded at boot |
17
+ | Takes effect | **hot** — no restart | at the next restart |
18
+ | Can add | HTTP tools, MCP toolsets, skills, prompts | tools, workflows, capabilities, policies, middleware, hooks, context providers |
19
+ | Reach for it when | you are calling an external API | logic must run in-process, or you are extending the engine itself |
20
+
21
+ Tier 1 is the path for "the community adds integrations". Tier 2 is the path for
22
+ "the community extends the engine". Most integrations are tier 1, and you should
23
+ feel mildly suspicious of yourself when you reach past it.
24
+
25
+ ## Tier 1 — extend with data
26
+
27
+ Nothing to install and nothing to deploy: a **data tool** is an HTTP call
28
+ described by config, and an **MCP import** turns a whole MCP server's toolset
29
+ into data tools in one call. Both are covered in [Tools](TOOLS.md) — the schema,
30
+ the `{{param}}` / `{{ctx.*}}` / `{{secret.*}}` placeholders, the four write paths,
31
+ and the egress guard.
32
+
33
+ Skills are the other half of tier 1: a `SKILL.md` is knowledge, not code, and it
34
+ can be authored in the Studio or shipped inside a pack. See [Skills](SKILLS.md).
35
+
36
+ ## Tier 2 — extend with code
37
+
38
+ A code plugin is **a directory with a manifest**. The manifest is discovery
39
+ without execution: Insika reads and validates it first, and only then requires
40
+ your Ruby.
41
+
42
+ ```
43
+ my-plugin/
44
+ ├── insika.plugin.yml # the manifest — always read first
45
+ ├── plugin.rb # the entry: defines a module with .register(api)
46
+ └── skills/ # optional: SKILL.md files shipped with the plugin
47
+ ```
48
+
49
+ ```yaml
50
+ # insika.plugin.yml
51
+ id: weather # unique; the id everything else keys on
52
+ name: Weather
53
+ description: Weather lookups. Ships the get_weather tool and a weather_report skill.
54
+ entry: plugin.rb # required to register anything; omit for a skills-only plugin
55
+ module: WeatherPlugin # must respond to .register(api)
56
+ contracts: # the PUBLIC surface — declared here or ignored
57
+ tools: [get_weather]
58
+ workflows: []
59
+ capabilities: []
60
+ channels: []
61
+ tool_metadata:
62
+ get_weather:
63
+ optional: false # optional tools require per-agent opt-in
64
+ side_effect: false # true ⇒ not re-run on resume (checkpointed)
65
+ skills: [skills] # directories, relative to the plugin root
66
+ prompts: []
67
+ ```
68
+
69
+ ```ruby
70
+ # plugin.rb
71
+ require "ruby_llm"
72
+
73
+ module WeatherPlugin
74
+ class GetWeather < RubyLLM::Tool
75
+ description "Looks up the current weather for a city"
76
+ param :city, desc: "City name"
77
+
78
+ def execute(city:) = { city: city, temp_c: 24, condition: "sunny" }
79
+ end
80
+
81
+ def self.register(api)
82
+ api.register_tool("get_weather", GetWeather)
83
+ end
84
+ end
85
+ ```
86
+
87
+ Two runnable ones live in the repo:
88
+ [`plugins/weather`](https://github.com/guizaols/insika/tree/main/plugins/weather)
89
+ (the minimal shape) and
90
+ [`plugins/insika-code`](https://github.com/guizaols/insika/tree/main/plugins/insika-code)
91
+ (a real toolset: file read/write/edit, grep, shell — sandboxed, with the
92
+ side-effecting tools marked so an agent can gate them behind approval).
93
+
94
+ ### What `register(api)` can register
95
+
96
+ | Call | Registers | Declared in `contracts`? |
97
+ |---|---|---|
98
+ | `register_tool(name, klass)` | a tool the model can call | **yes** — `contracts.tools` |
99
+ | `register_workflow(name, callable)` | a named workflow (see [Architecture](ARCHITECTURE.md)) | **yes** — `contracts.workflows` |
100
+ | `register_capability(name, tool:)` | an intent that resolves to a tool | **yes** — `contracts.capabilities` |
101
+ | `register_channel(name, instance)` | a way in and out for people (see [Channels](CHANNELS.md)) | **yes** — `contracts.channels` |
102
+ | `register_policy(name, klass)` | a policy for the resolution stage | no |
103
+ | `register_middleware(instance)` | a wrap around the turn pipeline | no |
104
+ | `register_context_provider(instance)` | a source of prompt context | no |
105
+ | `register_hook(:tool, before:, after:)` | alters one stage's input/output (`:task`, `:prompt`, `:agent`, `:tool`) | no |
106
+
107
+ Anything addressable by name must be declared in `contracts`; registering an
108
+ undeclared name logs a warning and is **ignored**, so a plugin cannot quietly
109
+ widen its own surface between versions. For a channel the name is also a URL
110
+ segment (`/channels/<name>/…`), so the declaration is what stops a plugin
111
+ from mounting a route nobody asked for.
112
+
113
+ `api.config` returns the manifest's `config` hash, frozen.
114
+
115
+ ### Failure is contained, and quiet
116
+
117
+ Registration is staged and committed atomically: if `register(api)` raises
118
+ halfway through, everything it staged is rolled back and the plugin is
119
+ discarded. **Boot continues** — one bad plugin must not take the deployment
120
+ down.
121
+
122
+ > ⚠️ The cost of that choice: a broken plugin is a `warn` on stderr, not a crash.
123
+ > If a tool is missing, check the boot log and the `:plugin_loaded` events before
124
+ > suspecting the allowlist.
125
+
126
+ The same posture applies to config: if `config_schema` is present and `config`
127
+ fails it, the plugin is **skipped** (fail-closed) with the validation errors
128
+ printed. The validator is a deliberate subset of JSON Schema — `type`,
129
+ `properties`, `required`, `additionalProperties`, `enum` — and an unsupported
130
+ keyword is itself an error rather than being silently ignored.
131
+
132
+ ### Discovery and enabling
133
+
134
+ Plugins come from three kinds of root, and they differ in **who has to say yes**:
135
+
136
+ | Root | How it is found | Enabled by default |
137
+ |---|---|---|
138
+ | **Gem** | the gem calls `Insika::Plugin.announce(__dir__)` when its `lib/` loads | **yes** — installing it is the consent |
139
+ | **Workspace** | a directory in the deployment's plugin roots | no — must be listed in `enabled:` |
140
+ | **Bundled** | `plugins/` in this repo | no — must be listed in `enabled:` |
141
+
142
+ `disabled:` is an absolute veto: an id listed there never loads, even if it is
143
+ also in `enabled:` (deny wins, the same rule as every allowlist in the engine).
144
+
145
+ A gem announces itself explicitly — Insika never scans the load path or your
146
+ installed gems:
147
+
148
+ ```ruby
149
+ # lib/insika-plugin-acme.rb
150
+ require "insika/plugin"
151
+ Insika::Plugin.announce(File.expand_path("../..", __dir__))
152
+ ```
153
+
154
+ Discovery details worth knowing: the manifest may be named `insika.plugin.yml`
155
+ (preferred) or `plugin.yml` (deprecated — it warns); one manifest per directory,
156
+ with `insika.plugin.yml` winning if both exist; and if two roots ship the same
157
+ `id`, the **first root wins** and the second is skipped.
158
+
159
+ ### Secrets
160
+
161
+ Put the *name* of the environment variable in the manifest, never the value:
162
+
163
+ ```yaml
164
+ config:
165
+ api_key_env: ACME_API_KEY
166
+ config_schema:
167
+ type: object
168
+ required: [api_key_env]
169
+ properties:
170
+ api_key_env: { type: string }
171
+ ```
172
+
173
+ The manifest is committed; the secret is not. This mirrors how data tools handle
174
+ `{{secret.*}}`.
175
+
176
+ ## Publishing a plugin
177
+
178
+ - **Name it `insika-plugin-<thing>`.** The convention *is* the registry for now:
179
+ a predictable RubyGems prefix plus a curated list here beats a hub nobody has
180
+ had a reason to build yet.
181
+ - **Announce in your gem's entry file** (above), so installing it is enough.
182
+ - **Treat `contracts` as your public API.** Renaming a tool, changing its
183
+ parameters, or adding a `required` config key are breaking changes for the
184
+ agents that allowlist them by name. Version accordingly.
185
+ - **Do not require `insika` at load time** if you can avoid it —
186
+ `insika/plugin` is deliberately dependency-free so a plugin gem can announce
187
+ itself before anything else of ours is loaded.
188
+
189
+ > **Compatibility, stated honestly:** Insika is pre-1.0 and nothing is tagged
190
+ > yet. The manifest keys and the `register(api)` surface above are what a plugin
191
+ > depends on; changes to them are listed in
192
+ > [`CHANGELOG.md`](https://github.com/guizaols/insika/blob/main/CHANGELOG.md).
193
+ > Until 1.0, pin the engine version you tested against.
194
+
195
+ ## Choosing a tier
196
+
197
+ | You want to… | Tier |
198
+ |---|---|
199
+ | call a REST API the model can invoke | **1** — data tool |
200
+ | adopt an existing MCP server's tools | **1** — MCP import |
201
+ | add domain knowledge or a procedure | **1** — a skill |
202
+ | touch the filesystem, run a subprocess, hold state in-process | **2** |
203
+ | add a policy, a middleware, or a context provider | **2** |
204
+ | package the above for other deployments to install | **2**, as a gem |
205
+
206
+ ## See also
207
+
208
+ - [Tools](TOOLS.md) — data tools, MCP ingestion, allowlists, and the egress guard.
209
+ - [Skills](SKILLS.md) — `SKILL.md`, progressive disclosure, and skill packs.
210
+ - [Sandbox](SANDBOX.md) — confining a code plugin that touches the filesystem or shell.
211
+ - [Architecture](ARCHITECTURE.md) — where plugins hook into the turn pipeline.