@gjermundgaraba/effect-actions 0.18.2 → 0.20.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 (155) hide show
  1. package/README.md +20 -47
  2. package/dist/Action.d.mts +96 -49
  3. package/dist/Action.mjs +12 -16
  4. package/dist/ActionCli.d.mts +82 -44
  5. package/dist/ActionCli.mjs +54 -35
  6. package/dist/ActionHttp.d.mts +84 -45
  7. package/dist/ActionHttp.mjs +3 -4
  8. package/dist/ActionMcp.d.mts +54 -29
  9. package/dist/ActionMcp.mjs +33 -15
  10. package/dist/ActionRpc.d.mts +48 -37
  11. package/dist/ActionRpc.mjs +3 -4
  12. package/dist/ActionToolkit.d.mts +32 -15
  13. package/dist/ActionToolkit.mjs +11 -9
  14. package/dist/Authentication.d.mts +92 -42
  15. package/dist/Authentication.mjs +38 -37
  16. package/dist/Testing.d.mts +23 -27
  17. package/dist/Testing.mjs +1 -2
  18. package/dist/authentication/provider.d.mts +1 -6
  19. package/dist/authentication/provider.mjs +2 -3
  20. package/dist/authentication/refusal.mjs +3 -3
  21. package/dist/cli/commands.d.mts +34 -13
  22. package/dist/cli/commands.mjs +48 -24
  23. package/dist/contract/ast.mjs +1 -2
  24. package/dist/contract/call.d.mts +1 -3
  25. package/dist/contract/call.mjs +1 -2
  26. package/dist/contract/caller.d.mts +1 -3
  27. package/dist/contract/caller.mjs +2 -3
  28. package/dist/contract/declared.d.mts +0 -2
  29. package/dist/contract/declared.mjs +28 -16
  30. package/dist/contract/errors.d.mts +42 -19
  31. package/dist/contract/errors.mjs +43 -19
  32. package/dist/contract/hints.mjs +4 -4
  33. package/dist/contract/implementation.d.mts +7 -14
  34. package/dist/contract/implementation.mjs +31 -19
  35. package/dist/contract/media.d.mts +7 -5
  36. package/dist/contract/media.mjs +7 -4
  37. package/dist/contract/names.mjs +15 -14
  38. package/dist/contract/status.mjs +4 -12
  39. package/dist/http/client.d.mts +15 -6
  40. package/dist/http/client.mjs +2 -3
  41. package/dist/http/schema-errors.mjs +1 -2
  42. package/dist/mcp/client.d.mts +21 -10
  43. package/dist/mcp/client.mjs +8 -11
  44. package/dist/mcp/protocol.mjs +1 -2
  45. package/dist/stdio/console.mjs +1 -2
  46. package/dist/testing/memory.d.mts +1 -3
  47. package/dist/testing/memory.mjs +1 -2
  48. package/docs/Action-failures.md +6 -15
  49. package/docs/Action.md +159 -191
  50. package/docs/ActionCli-failures.md +12 -92
  51. package/docs/ActionCli.md +102 -156
  52. package/docs/ActionHttp-failures.md +14 -76
  53. package/docs/ActionHttp.md +150 -176
  54. package/docs/ActionMcp-failures.md +2 -22
  55. package/docs/ActionMcp.md +79 -170
  56. package/docs/ActionRpc-failures.md +3 -12
  57. package/docs/ActionRpc.md +183 -120
  58. package/docs/ActionToolkit-failures.md +3 -10
  59. package/docs/ActionToolkit.md +112 -109
  60. package/docs/Authentication-advanced.md +171 -108
  61. package/docs/Authentication-failures.md +11 -91
  62. package/docs/Authentication.md +114 -135
  63. package/docs/README.md +45 -30
  64. package/docs/Testing-failures.md +2 -6
  65. package/docs/Testing.md +126 -159
  66. package/docs/setup-failures.md +8 -32
  67. package/docs/setup.md +42 -38
  68. package/examples/README.md +6 -10
  69. package/examples/assistant/README.md +20 -38
  70. package/examples/assistant/src/agent.ts +2 -3
  71. package/examples/assistant/src/cli.ts +4 -9
  72. package/examples/assistant/src/commands.ts +6 -4
  73. package/examples/assistant/src/identity/authorize.ts +3 -3
  74. package/examples/assistant/src/tasks/Tasks.ts +1 -2
  75. package/examples/assistant/src/tasks/handlers.ts +3 -3
  76. package/examples/assistant/src/toolkit.ts +6 -8
  77. package/examples/assistant/test/commands.test.ts +16 -11
  78. package/examples/assistant/test/tasks/tasks.test.ts +0 -2
  79. package/examples/assistant/test/toolkit.test.ts +0 -1
  80. package/examples/kitchen-sink/README.md +30 -52
  81. package/examples/kitchen-sink/src/Actions.ts +2 -3
  82. package/examples/kitchen-sink/src/Api.ts +3 -4
  83. package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
  84. package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
  85. package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
  86. package/examples/kitchen-sink/src/agent.ts +1 -1
  87. package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
  88. package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
  89. package/examples/kitchen-sink/src/attachments/download.ts +5 -8
  90. package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
  91. package/examples/kitchen-sink/src/browser.ts +3 -4
  92. package/examples/kitchen-sink/src/cli.ts +6 -7
  93. package/examples/kitchen-sink/src/config.ts +2 -0
  94. package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
  95. package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
  96. package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
  97. package/examples/kitchen-sink/src/identity/quota.ts +1 -1
  98. package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
  99. package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
  100. package/examples/kitchen-sink/src/issues/guide.ts +2 -3
  101. package/examples/kitchen-sink/src/ops.ts +3 -6
  102. package/examples/kitchen-sink/src/request-policy.ts +8 -4
  103. package/examples/kitchen-sink/src/routes.ts +4 -6
  104. package/examples/kitchen-sink/src/seed.ts +0 -1
  105. package/examples/kitchen-sink/src/server.ts +11 -12
  106. package/examples/kitchen-sink/src/stdio.ts +3 -2
  107. package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
  108. package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
  109. package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
  110. package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
  111. package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
  112. package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
  113. package/examples/kitchen-sink/test/request-policy.test.ts +39 -3
  114. package/examples/kitchen-sink/test/routes.test.ts +3 -3
  115. package/examples/live-rpc/README.md +8 -49
  116. package/examples/live-rpc/src/browser.ts +8 -41
  117. package/examples/live-rpc/src/chat/Chat.ts +9 -3
  118. package/examples/live-rpc/src/chat/room.ts +1 -1
  119. package/examples/live-rpc/src/client.ts +2 -3
  120. package/examples/live-rpc/src/page.ts +34 -0
  121. package/examples/live-rpc/src/routes.ts +6 -7
  122. package/examples/live-rpc/src/server.ts +6 -4
  123. package/examples/live-rpc/test/browser.test.ts +6 -5
  124. package/examples/live-rpc/test/routes.test.ts +16 -15
  125. package/examples/live-rpc/test/server.test.ts +1 -1
  126. package/examples/mcp-tools/README.md +22 -45
  127. package/examples/mcp-tools/src/printers/Printers.ts +3 -7
  128. package/examples/mcp-tools/src/routes.ts +0 -7
  129. package/examples/mcp-tools/src/server.ts +1 -1
  130. package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
  131. package/examples/mcp-tools/test/routes.test.ts +2 -3
  132. package/examples/notes-service/README.md +9 -43
  133. package/examples/notes-service/src/cli.ts +0 -3
  134. package/examples/notes-service/src/commands.ts +7 -17
  135. package/examples/notes-service/src/identity/authorize.ts +2 -5
  136. package/examples/notes-service/src/identity/verifier.ts +3 -4
  137. package/examples/notes-service/src/notes/Notes.ts +2 -6
  138. package/examples/notes-service/src/notes/store.ts +5 -1
  139. package/examples/notes-service/src/routes.ts +6 -8
  140. package/examples/notes-service/src/server.ts +4 -3
  141. package/examples/notes-service/src/stdio.ts +4 -3
  142. package/examples/notes-service/test/commands.test.ts +1 -1
  143. package/examples/notes-service/test/notes/as.ts +6 -0
  144. package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
  145. package/examples/notes-service/test/notes/in-process.test.ts +50 -0
  146. package/examples/notes-service/test/notes/notes.test.ts +3 -42
  147. package/examples/notes-service/test/routes.test.ts +2 -2
  148. package/examples/quickstart/README.md +1 -1
  149. package/examples/quickstart/src/client.ts +1 -0
  150. package/examples/quickstart/src/server.ts +2 -4
  151. package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
  152. package/package.json +12 -16
  153. package/docs/Action-in-process.md +0 -93
  154. package/docs/CONTEXT.md +0 -46
  155. package/docs/guarantees.md +0 -44
@@ -4,139 +4,142 @@ Implementations as Effect's native AI `Toolkit`: one tool per action, run in the
4
4
  calls the model, with no server. Reach for it when a `LanguageModel` or a `Chat` in your program
5
5
  calls your actions. Nothing authenticates: the host names the caller. Code of your own calls
6
6
  the same implementations typed, with `Action.client`
7
- ([Action-in-process.md](Action-in-process.md#call-actions-in-process)).
7
+ ([Action.md](Action.md#call-actions-in-process)).
8
8
 
9
- Read this page alone, in one tool call; if its output says it was truncated, read it again alone.
10
- When something fails, search [ActionToolkit-failures.md](ActionToolkit-failures.md). Reading order:
11
- [README.md](README.md).
12
-
13
- ## API
14
-
15
- Import `@gjermundgaraba/effect-actions/ActionToolkit`.
16
-
17
- | API | Purpose |
18
- | --------------------------------- | ------------------------------------------------------------------------------ |
19
- | `make(implementations, options?)` | Project an implementation or a list; returns `{ toolkit, layer }`. |
20
- | `toolkit` | A native `Toolkit`, one tool per action, named after it. |
21
- | `layer` | The tools' handlers. Requires the startup services their builders read. |
22
- | `openApprovals(history)` | The approval requests a `Chat`'s history still holds open, each with its call. |
23
-
24
- | Option | Meaning |
25
- | --------------- | -------------------------------------------------------------------------------------------------------------- |
26
- | `actions` | The actions that are tools: `[GetUser, RenameUser]`. Defaults to every action of the implementations. |
27
- | `needsApproval` | `(call, context) =>` a boolean, or an `Effect` of one: the calls a model must have approved. Defaults to none. |
28
-
29
- Exported types: `Tools`, what `make` returns; `Options`, what it takes; `ToolCall<A>`, the call `needsApproval` receives, for a rule written apart from `make`; `Approval`, what `openApprovals` lists.
9
+ A message the library throws says its fix; for a compiler diagnostic or Effect's own error, search [ActionToolkit-failures.md](ActionToolkit-failures.md). For one symbol, `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`: each declaration's JSDoc carries its rules and gotchas, and this page only what an example cannot show.
30
10
 
31
11
  ## Give a model tools
32
12
 
33
- Pass `toolkit` to `LanguageModel.generateText` or a `Chat`, and provide `layer`:
34
- [assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts) and
35
- [assistant/src/agent.ts](../examples/assistant/src/agent.ts), with a scripted model in
36
- [assistant/src/scripted.ts](../examples/assistant/src/scripted.ts).
37
-
38
- ```ts
39
- export const { toolkit, layer } = ActionToolkit.make([tasks, identity]);
13
+ `ActionToolkit.make` projects implementations into `{ toolkit, layer }`. Pass `toolkit` to
14
+ `LanguageModel.generateText` or a `Chat`, and provide `layer` once, around the program:
15
+
16
+ ```ts example=assistant/src/toolkit.ts
17
+ import { Effect, Option } from "effect";
18
+ import { Tool, Toolkit } from "effect/ai";
19
+ import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
20
+ import { identity } from "./identity/handlers.js";
21
+ import { CurrentCaller, type Member } from "./identity/Identity.js";
22
+ import { tasks } from "./tasks/handlers.js";
23
+
24
+ // `actions.layer` holds the handlers, and requires the startup services their builders read;
25
+ // neither it nor `actions.toolkit` holds a caller.
26
+ const actions = ActionToolkit.make([tasks, identity], {
27
+ // One rule over every call a model makes, asked before the call runs. A read runs at once. A
28
+ // write waits for a person to approve it, except a task the caller adds to their own list:
29
+ // `call.name` narrows `call.input` to that action's. Without a caller, it asks.
30
+ needsApproval: (call) =>
31
+ !call.action.readOnly &&
32
+ Effect.map(
33
+ Effect.serviceOption(CurrentCaller),
34
+ Option.match({
35
+ onNone: () => true,
36
+ onSome: (caller) => call.name !== "addTask" || call.input.assignee !== caller.id,
37
+ }),
38
+ ),
39
+ });
40
+
41
+ // A title for this assistant's approval display. The model still calls the tool `addTask`.
42
+ // The later definition wins; annotating the generated tool keeps its handler and approval rule.
43
+ export const toolkit = Toolkit.merge(
44
+ actions.toolkit,
45
+ Toolkit.make(actions.toolkit.tools.addTask.annotate(Tool.Title, "Add a task")),
46
+ );
40
47
 
41
- // One turn, as a member.
42
- const turn = chat.generateText({ prompt, toolkit }).pipe(actingFor(member));
48
+ export const layer = actions.layer;
43
49
 
44
- conversations.pipe(
45
- Effect.provide(model),
46
- // Once, around the program. Startup services only, never a caller.
47
- Effect.provide(layer.pipe(Layer.provide([tasksFile, Permissions.layerMemory]))),
48
- );
50
+ // One toolkit serves every member, so who a turn runs as is provided around that turn. This is
51
+ // how this example names a model's caller, and it takes a `Member`. The tools themselves accept
52
+ // any `CurrentCaller`, so keeping the operator away from a model is this function's doing, and
53
+ // that of every host using it.
54
+ export const actingFor = (member: Member) => Effect.provideService(CurrentCaller, member);
49
55
  ```
50
56
 
51
- - **One `generateText` is one request to the model.** Its tool calls run, and their results come back in the response, unread by the model. For the model to read them, ask the `Chat` again with `prompt: []` while a response has `toolCalls` and no `tool-approval-request`.
52
- - Without `actions`, every action of the implementations is a tool, one added later included. Where a model must not get them all, list the tools ([Action.md](Action.md#define-a-contract)): [kitchen-sink/src/issues/assistant.ts](../examples/kitchen-sink/src/issues/assistant.ts).
53
- - A refusal or a declared error is not raised: it is a tool result with `isFailure: true`, which the model reads. So are arguments that do not decode, an `AiError` (`ToolParameterValidationError`), not `InvalidInput`: neither `authorize` nor the handler ran.
54
- - A turn's tool calls, the approved calls of an answering turn included, run concurrently unless `generateText` is given a `concurrency`. A service they share must tolerate it: [assistant/src/tasks/store.ts](../examples/assistant/src/tasks/store.ts) locks each change.
55
- - Arguments the input does not declare are dropped, where HTTP and a CLI refuse them ([guarantees.md](guarantees.md#wire-behavior)): a misspelled optional argument is ignored silently.
56
- - **A model loop behind a route.** Yield `toolkit` and the model in the implementation's builder, `Action.implement(Ask, Effect.map(toolkit, (tools) => (input) => ...))`: what the handler itself yields is owed per request. Provide `layer` to the route's layer, `ActionHttp.layer(Http, ask).pipe(Layer.provide(layer))`, not in the handler, so the tools share the routes' build ([Action-in-process.md](Action-in-process.md#share-one-build-across-servers)). A tool call runs as the request's caller, unless the handler provides another around it. Keep each caller's `Chat` in a service between requests ([kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)), and answer an approval in a later one: [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts), with a scripted model in [demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts), which the entrypoint provides through [kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts), and tested over HTTP in [kitchen-sink/test/assistant/assistant.test.ts](../examples/kitchen-sink/test/assistant/assistant.test.ts). Name the files as it does, the route's contract `Assistant.ts` and the loop `handlers.ts`, from the first one written: a lowercase `assistant.ts` beside the contract fails with TS1149 ([setup-failures.md](setup-failures.md)).
57
- - **The real model comes later?** Provide a scripted one from the entrypoint until then, as the kitchen-sink does. Reading the model with `Effect.serviceOption` hides the requirement: the server builds without one and every request fails.
57
+ [assistant/src/agent.ts](../examples/assistant/src/agent.ts) runs the turns and provides `layer`
58
+ with its startup services, with a scripted model in
59
+ [assistant/src/scripted.ts](../examples/assistant/src/scripted.ts). What a call returns, a
60
+ refusal and arguments that do not decode included, is on `ActionToolkit.make`; the options'
61
+ rules are on `ActionToolkit.Options`.
62
+
63
+ - **Let the model read its tools' results** by asking again:
64
+ [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts).
65
+ - Where a model must not get every action, list the tools
66
+ ([Action.md](Action.md#define-a-contract)):
67
+ [kitchen-sink/src/issues/assistant.ts](../examples/kitchen-sink/src/issues/assistant.ts).
68
+ - A turn's tool calls, the approved calls of an answering turn included, run concurrently unless
69
+ `generateText` is given a `concurrency`:
70
+ [assistant/src/tasks/store.ts](../examples/assistant/src/tasks/store.ts) locks each change.
71
+ - **A model loop behind a route.** Yield `toolkit` and the model in the implementation's
72
+ builder, and provide `layer` to the route's layer,
73
+ `ActionHttp.layer(Http, ask).pipe(Layer.provide(layer))`, so the tools share the routes' build
74
+ ([Action.md](Action.md#share-one-build-across-servers)):
75
+ [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts),
76
+ keeping each caller's `Chat` in
77
+ [conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts), with a scripted
78
+ model in [demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts) that the
79
+ entrypoint provides through [kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts),
80
+ tested over HTTP in
81
+ [kitchen-sink/test/assistant/assistant.test.ts](../examples/kitchen-sink/test/assistant/assistant.test.ts).
82
+ Name the files as it does, the route's contract `Assistant.ts` and the loop `handlers.ts`,
83
+ from the first one written.
84
+ - **The real model comes later?** Provide a scripted one from the entrypoint until then, as the
85
+ kitchen-sink does. Reading the model with `Effect.serviceOption` hides the requirement: the
86
+ server builds without one and every request fails.
58
87
 
59
88
  ### Customize the native tools
60
89
 
61
- Give a generated tool a title for your approval display with native `Tool` and `Toolkit` from
62
- `effect/ai`: [assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts). The host reads the
63
- title when printing approval requests in [agent.ts](../examples/assistant/src/agent.ts).
90
+ Give a generated tool a title, or other annotations, with native `Tool` and `Toolkit` from
91
+ `effect/ai`, as `toolkit` above does; [agent.ts](../examples/assistant/src/agent.ts) reads the
92
+ title when printing approval requests.
64
93
 
65
- ```ts
66
- const actions = ActionToolkit.make([tasks, identity]);
67
- const toolkit = Toolkit.merge(
68
- actions.toolkit,
69
- Toolkit.make(actions.toolkit.tools.addTask.annotate(Tool.Title, "Add a task")),
70
- );
71
- const layer = actions.layer;
72
- ```
73
-
74
- - `Toolkit.merge` keeps the later definition on a name collision. Put the customized one last;
75
- keep names distinct when adding unrelated tools.
76
- - `annotate` and `annotateMerge` return a new definition preserving the generated tool's handler
77
- identity and approval rule. Keep the original `layer`. Recreating it with `Tool.make` under
78
- the same name does not preserve that binding.
79
94
  - To add other native tools, use `Toolkit.merge(toolkit, others)` and provide both handler layers
80
95
  with `Layer.mergeAll(layer, othersHandlers)`.
81
96
 
82
97
  ### Call a tool by hand
83
98
 
84
- `yield* toolkit` gives `tools.handle(name, arguments)`, which takes the JSON a model sends, an
85
- ISO string for a `Schema.Date`, and returns an Effect of a stream of results:
86
- [assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts). The handler starts
87
- when `handle` runs, so provide the caller around the whole
88
- `handle(...).pipe(Effect.flatMap(Stream.runCollect))`, not around the stream alone. `handle`
89
- never asks for approval.
99
+ `yield* toolkit` gives `tools.handle(name, arguments)`:
100
+ [assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts). Its rules, where
101
+ to provide the caller among them, are on `ActionToolkit.Tools`.
90
102
 
91
103
  ## Name the caller
92
104
 
93
105
  Provide the identity around each turn, or each call made by hand, as `actingFor` does in
94
- [assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts): the approval rule, `authorize`
95
- and the handlers of every call in the turn read it.
96
-
97
- - Never provide an identity to `layer`, which is built once for every caller. It satisfies no protected tool's call, which still owes its own in its type, and it silently answers for any call that lacks one.
98
- - A protected action's tool owes its identity whether or not its handler reads it ([Action.md](Action.md#say-who-may-call-an-action)).
106
+ [assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts). Why never to `layer`, and that
107
+ a protected tool owes it whatever its handler reads, are on `Action.Options.caller`
108
+ ([Action.md](Action.md#say-who-may-call-an-action)).
99
109
 
100
110
  ## Ask for approval
101
111
 
102
- `needsApproval` is one rule over every call a model makes, asked before the call runs. Every
103
- write: `needsApproval: (call) => !call.action.readOnly`
112
+ To ask before every write: `needsApproval: (call) => !call.action.readOnly`
104
113
  ([kitchen-sink/src/issues/assistant.ts](../examples/kitchen-sink/src/issues/assistant.ts)). A
105
- rule reading the call's input and its caller:
106
- [assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts).
107
-
108
- - `call` is `{ name, action, input }`, the input decoded, over every action of the implementations, whatever `actions` lists. Check `call.name` to narrow the other two.
109
- - The rule's `Effect` requires nothing: read the caller with `Effect.serviceOption`, and decide what the absence of one means.
110
- - Its `Effect` cannot fail in its type, and one that fails anyway runs the call unapproved. To ask when a policy cannot be read, end it with `Effect.orElseSucceed(() => true)`.
111
- - Approval is not authorization, which stays `authorize`. Only a model's turn asks for it, and `ActionMcp` has no such option.
114
+ rule reading the call's input and its caller is `toolkit` above; its rules are on
115
+ `ActionToolkit.Options`.
112
116
 
113
117
  A call that needs approval does not run: the turn ends with a `tool-approval-request` part in
114
- `response.content` in place of its result. Start the next turn with the person's answers
115
- ([assistant/src/agent.ts](../examples/assistant/src/agent.ts)):
116
-
117
- ```ts
118
- chat
119
- .generateText({
120
- toolkit,
121
- prompt: [
122
- {
123
- role: "tool",
124
- content: asked.content.flatMap((part) =>
125
- part.type === "tool-approval-request"
126
- ? [{ type: "tool-approval-response", approvalId: part.approvalId, approved: true }]
127
- : [],
128
- ),
129
- },
130
- ],
131
- })
132
- .pipe(actingFor(member));
133
- ```
134
-
135
- - The answer needs the prompt history, where its request is found by `approvalId`. Sent alone through a bare `LanguageModel.generateText`, it runs nothing and raises nothing. Use a `Chat`, or send again the assistant message holding the call and its request, before the answer.
136
- - An approved call runs in the answering turn, before the model is asked again, as whoever that turn runs as: provide the caller who was asked. `authorize` runs for it too ([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)).
137
- - **Each request is answered on its own.** Of a turn's several requests, an approved call runs, a denied one (`approved: false`) does not and gives the model a failed result (`{ type: "execution-denied", reason }`, whose `reason` is `undefined` unless the answer gives one: not JSON: a route answering with it as a `Schema.Unknown` success answers an empty 500), and one left unanswered stays open for a later turn. To require every answer at once, check it in the host. Refuse an `approvalId` answered twice in one request: every answer is acted on, so its call would run twice. The answering turn's response does not repeat a request left open: list every open one with `ActionToolkit.openApprovals(yield* Ref.get(chat.history))`, as [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts) does. Effect finds an approval's call by its tool-call id, which a provider makes unique: where a model of your own repeats one, the last call with it runs, which `openApprovals` names and `response.toolCalls` may not.
138
- - **Continue on `toolCalls`, never on `toolResults`.** The answering turn's response holds the approved call's result, no tool call, and the model's reply to it: a loop that continues while a response has tool results asks the model again and loses that reply.
139
- - **If a turn fails or is interrupted once an approved call has started**, the model failing or the client going away, the call may have run, and may run again. A failed `generateText` records nothing of its turn, so the same answer sent again runs the call again. A failed `streamText` records its prompt and the parts it streamed: a call that returned has its result recorded, but one that failed or was cut off while running leaves its answer without a result, which the next turn acts on unasked; `openApprovals` leaves that request out, as another answer would run the call twice. End the conversation where a turn does not succeed, or record an answer in your own store before its turn, and say in the error that an approved call may have run. Run one turn of a conversation at a time, and look it up again once the lock is held, so a turn that waited does not run on a conversation just ended: [kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts) does both.
140
- - **Show the person what is asked.** A request names its call by `toolCallId`; that call, in `response.toolCalls`, holds the model's `params`, typed `unknown`. Show them as JSON, as [assistant/src/agent.ts](../examples/assistant/src/agent.ts) does, which joins the two; decode them with `Schema.toCodecJson(AddTask.input)`, the tool's parameters, only where you read a field.
141
- - **If repeating a write would create an unwanted additional effect, retries need application-owned deduplication.** Keep the same operation key across attempts, as in [notes-service's creation example](../examples/notes-service/README.md#retry-a-creation). Approval history alone does not make the mutation and its result record atomic.
142
- - **Only the person asked answers.** A `tool-approval-response` whose `approvalId` matches a request in the history without a result is acted on, as the turn's caller, an answered one included: take an `approvalId` only from that caller, among those `openApprovals` lists, and keep the `Chat` where its owner alone reaches it.
118
+ `response.content` in place of its result. Start the next turn with the person's answers, in a
119
+ `Chat`, as the caller who was asked:
120
+ [assistant/src/agent.ts](../examples/assistant/src/agent.ts) shows both and says why, tested in
121
+ [assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts).
122
+
123
+ - **Each request is answered on its own**: an approved call runs, a denied one does not, and one
124
+ left unanswered stays open for a later turn; to require every answer at once, check it in the
125
+ host. List the open ones with `ActionToolkit.openApprovals(yield* Ref.get(chat.history))`.
126
+ The kitchen-sink refuses an `approvalId` answered twice
127
+ ([Assistant.ts](../examples/kitchen-sink/src/assistant/Assistant.ts)), gives each call its own
128
+ id ([demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts)) and lets only its
129
+ owner reach a conversation
130
+ ([conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)).
131
+ - A denied call's result, `{ type: "execution-denied", reason }`, has `reason` `undefined` unless
132
+ the answer gives one, so it is not JSON: a route answering it as a `Schema.Unknown` success
133
+ answers an empty 500.
134
+ - **If a turn fails or is interrupted once an approved call has started**, the call may have
135
+ run: end the conversation and say so in the error, as
136
+ [kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)
137
+ and [handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts) do. What a failed
138
+ `streamText` leaves is on `ActionToolkit.openApprovals`.
139
+ - **Show the person what is asked**: [assistant/src/agent.ts](../examples/assistant/src/agent.ts)
140
+ joins each request to its call by `toolCallId`; how to read the arguments is on
141
+ `ActionToolkit.Approval`.
142
+ - **If repeating a write would create an unwanted additional effect, retries need
143
+ application-owned deduplication.** Keep the same operation key across attempts, as in
144
+ [notes-service's creation example](../examples/notes-service/README.md#retry-a-creation).
145
+ Approval history alone does not make the mutation and its result record atomic.
@@ -3,137 +3,200 @@
3
3
  A verifier reading the request, an API key or a cookie, a route of your own, a policy before
4
4
  authentication, and a refusal outside the router.
5
5
 
6
- Read [Authentication.md](Authentication.md) first, and this page alone, in one tool call. When
7
- something fails, search [Authentication-failures.md](Authentication-failures.md). Reading order:
8
- [README.md](README.md).
9
-
10
- ## API
11
-
12
- Import `@gjermundgaraba/effect-actions/Authentication`. `make` and `layer`:
13
- [Authentication.md](Authentication.md#api).
14
-
15
- | API | Purpose |
16
- | -------------------------------------- | ----------------------------------------------------------------- |
17
- | `protect(descriptor)` | Router middleware authenticating a route of the host's own. |
18
- | `bearerTokenOf(authorization)` | The bearer token of an `Authorization` header value, an `Option`. |
19
- | `refusalResponse(error, options?)` | A refusal's response, for a caller the router never routes. |
20
- | `Protection`, `RefusalResponseOptions` | What `protect` returns, and what `refusalResponse` takes. |
21
-
22
- | `refusalResponse` option | Meaning |
23
- | ------------------------ | ---------------------------------------------------------------------------------------- |
24
- | `authentication` | The descriptor refusing: needed for an error it declares, or a scheme other than Bearer. |
25
- | `protectedResource` | The resource `layer` was given: challenges name its metadata URL and `scopesRequired`. |
26
- | `authorization` | The request's `Authorization` header: a 401 to a presented token says `invalid_token`. |
6
+ A message the library throws says its fix; for a compiler diagnostic or Effect's own error,
7
+ search [Authentication-failures.md](Authentication-failures.md). For one symbol,
8
+ `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`: each declaration's JSDoc
9
+ carries its rules and gotchas, and this page only what an example cannot show.
27
10
 
28
11
  ## Read request context in a verifier
29
12
 
30
13
  A verifier may read per-request services: the request, or one of your own, such as the tenant a
31
- request addresses, which native router middleware provides. Provide that middleware after the
32
- provider, in a `Layer.provide` of its own; in one array, the members provide to the routes and
33
- not to one another, and the tenant stays owed:
34
-
35
- ```ts
36
- routes.pipe(Layer.provide(authenticate), Layer.provide(resolveTenant.layer));
14
+ request addresses, which native router middleware provides:
15
+
16
+ ```ts example=kitchen-sink/src/workspaces/tenant.ts
17
+ import { Context, Effect, Option } from "effect";
18
+ import { HttpRouter, HttpServerRequest } from "effect/http";
19
+
20
+ /**
21
+ * The workspace a request addresses, from its `X-Workspace` header. It is request context, not
22
+ * an identity: the verifier reads it to decide which of an account's memberships the caller
23
+ * acts in. Handlers never read it; they read the workspace of the authenticated member.
24
+ */
25
+ export class Tenant extends Context.Service<Tenant, Option.Option<string>>()("tracker/Tenant") {}
26
+
27
+ // Native router middleware. It provides `Tenant` to whatever it is provided to, per request:
28
+ // to the verifier too, when it is provided after the authentication, in a `Layer.provide` of
29
+ // its own: in one array with the authentication it would leave `Tenant` owed, since an array's
30
+ // layers provide to the routes, not to one another.
31
+ export const resolveTenant = HttpRouter.middleware<{ provides: Tenant }>()((route) =>
32
+ Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) =>
33
+ Effect.provideService(route, Tenant, Option.fromNullishOr(request.headers["x-workspace"])),
34
+ ),
35
+ );
37
36
  ```
38
37
 
39
- See [kitchen-sink/src/workspaces/tenant.ts](../examples/kitchen-sink/src/workspaces/tenant.ts)
40
- and [kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts).
41
-
42
- - Router middleware provided around the routes runs before their authentication. Middleware
43
- that reads the identity runs inside it: give it to the layer as its own `middleware`
44
- ([ActionHttp.md](ActionHttp.md#serve-the-routes), [ActionRpc.md](ActionRpc.md#serve-the-rpcs)).
38
+ The verifier reading it is `authenticateBearer` in
39
+ [kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts),
40
+ and `publicRoutes` in [kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts)
41
+ provides the middleware.
45
42
 
46
43
  ## Use a scheme other than Bearer
47
44
 
48
- Name the scheme in `make`'s `security`: an API key in a cookie, a header or the query, such as
49
- `HttpApiSecurity.apiKey({ in: "cookie", key: "tracker_session" })`, or `HttpApiSecurity.basic`.
50
- See [kitchen-sink/src/identity/Identity.ts](../examples/kitchen-sink/src/identity/Identity.ts)
51
- and [kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts)
52
- for a session cookie beside Bearer, and [kitchen-sink/src/admin/Admin.ts](../examples/kitchen-sink/src/admin/Admin.ts)
53
- and [kitchen-sink/src/admin/authenticate.ts](../examples/kitchen-sink/src/admin/authenticate.ts)
45
+ Name the scheme in `Authentication.Options.security`, whose JSDoc says what each scheme gives
46
+ up. See `Session` in
47
+ [kitchen-sink/src/identity/Identity.ts](../examples/kitchen-sink/src/identity/Identity.ts) and
48
+ `authenticateSession` in
49
+ [kitchen-sink/src/identity/authenticate.ts](../examples/kitchen-sink/src/identity/authenticate.ts)
50
+ for a session cookie beside Bearer, and
51
+ [kitchen-sink/src/admin/Admin.ts](../examples/kitchen-sink/src/admin/Admin.ts) and
52
+ [kitchen-sink/src/admin/authenticate.ts](../examples/kitchen-sink/src/admin/authenticate.ts)
54
53
  for an API key in a header.
55
54
 
56
- - OAuth is Bearer's. Another scheme takes no `protectedResource` and nothing steps up under it:
57
- the scopes a `Forbidden` names there are sent in its JSON and challenge nothing.
58
- - A browser attaches a cookie to any page's request, so refuse a foreign `Origin` before
59
- authentication reads the cookie
60
- ([Run a request policy before authentication](#run-a-request-policy-before-authentication)).
61
- CORS guards the JSON routes alone ([ActionHttp.md](ActionHttp.md#call-the-api-from-a-browser));
62
- a WebSocket upgrade is never preflighted, so for rpcs over a socket under a cookie that policy
63
- is the only check ([ActionRpc.md](ActionRpc.md#send-request-context-with-a-call)).
64
- - A scheme the deployment decides, such as a cookie name a framework prefixes over HTTPS, is
65
- still one declaration: the server builds its descriptor at startup, under the same name and
66
- identity, and from that one value its binding, its endpoint and its `protect`; a browser's
67
- binding keeps a static one.
68
- - Basic's credential reaches the verifier when its username or its password is non-empty, so a
69
- verifier may receive an empty username.
55
+ For a cookie covering rpcs over a WebSocket, see
56
+ [ActionRpc.md](ActionRpc.md#send-request-context-with-a-call). A scheme the deployment decides,
57
+ such as a cookie name a framework prefixes over HTTPS, is still one declaration, as
58
+ `Authentication.layer` says.
70
59
 
71
60
  ## Protect a route of your own
72
61
 
73
- A descriptor covers action routes alone. A route of the host's own, such as a download or a
74
- WebSocket upgrade, takes `Authentication.protect(descriptor)`:
75
-
76
- ```ts
77
- HttpRouter.add("GET", "/attachments/:id", handler).pipe(
78
- Layer.provide(Authentication.protect(Session).layer),
62
+ A route of the host's own, such as a download or a WebSocket upgrade, takes
63
+ `Authentication.protect(descriptor)`, whose rules, authorization and caching included, are on
64
+ its JSDoc:
65
+
66
+ ```ts example=kitchen-sink/src/attachments/download.ts
67
+ import { Effect, Layer } from "effect";
68
+ import { HttpRouter, HttpServerResponse } from "effect/http";
69
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
70
+ import { authorize } from "../identity/authorize.js";
71
+ import { CurrentMember, Session } from "../identity/Identity.js";
72
+ import { GetAttachment } from "./Attachments.js";
73
+ import { AttachmentStore } from "./store.js";
74
+
75
+ // A route of the host's own: `GET /attachments/<id>` answers the image's bytes, for an `<img>`
76
+ // of the web app, which no action can (an action answers JSON), behind
77
+ // `Authentication.protect(Session)`: the page's cookie, which the web app's rpcs use. A token
78
+ // holder has no use for it: `getAttachment` gives an API client the same image.
79
+ export const download = Layer.unwrap(
80
+ Effect.map(AttachmentStore, (store) =>
81
+ HttpRouter.add(
82
+ "GET",
83
+ "/attachments/:id",
84
+ Effect.gen(function* () {
85
+ // The rule of the action it mirrors.
86
+ yield* authorize(GetAttachment);
87
+
88
+ const member = yield* CurrentMember;
89
+ const { id = "" } = yield* HttpRouter.params;
90
+ const { image } = yield* store.get(member.workspace, id);
91
+
92
+ // An attachment never changes, but it is one workspace's: `private`.
93
+ return HttpServerResponse.uint8Array(image.data, {
94
+ contentType: image.mimeType,
95
+ headers: {
96
+ "cache-control": "private, max-age=31536000, immutable",
97
+ // What a member uploaded is served from the application's own origin, and an image
98
+ // can be a document: an SVG opened by its URL, not in an `<img>`, runs its scripts
99
+ // as the member reading it. `sandbox` gives it no origin and no scripts, and
100
+ // `nosniff` keeps the browser to the type stated.
101
+ "content-security-policy": "sandbox",
102
+ "x-content-type-options": "nosniff",
103
+ },
104
+ });
105
+ }).pipe(
106
+ Effect.catchTag("AttachmentNotFound", () =>
107
+ Effect.succeed(HttpServerResponse.empty({ status: 404 })),
108
+ ),
109
+ ),
110
+ ).pipe(Layer.provide(Authentication.protect(Session).layer)),
111
+ ),
79
112
  );
80
113
  ```
81
114
 
82
- It answers a refusal the route fails with as an action's is answered. See
83
- [kitchen-sink/src/attachments/download.ts](../examples/kitchen-sink/src/attachments/download.ts)
84
- and [kitchen-sink/test/attachments/attachments.test.ts](../examples/kitchen-sink/test/attachments/attachments.test.ts).
85
-
86
- - No authorizer runs for such a route: it states its own authorization.
87
- - Every response to a request authentication covers is `Cache-Control: no-store` unless the
88
- route states its own caching. A route that does says `private` unless its answer is the same
89
- for every caller, or a cache in front of the server may serve it to anyone.
90
- - The route of an RPC protocol needs no `protect`: `ActionRpc.layer` authenticates each
91
- message itself.
115
+ [kitchen-sink/test/attachments/attachments.test.ts](../examples/kitchen-sink/test/attachments/attachments.test.ts)
116
+ asserts what it answers.
92
117
 
93
118
  ## Run a request policy before authentication
94
119
 
95
- A check that must refuse a request before its credential is read, such as a `Host` or `Origin`
96
- allowlist, is not the layer's own `middleware`, which runs inside authentication. Make it
97
- global router middleware, `HttpRouter.middleware(check, { global: true })`, which covers every
98
- request of the router, and provide it last:
120
+ For a check that must refuse a request before its credential is read, such as a `Host` or
121
+ `Origin` allowlist, make global router middleware, `HttpRouter.middleware(check, { global: true })`,
122
+ and provide it in the last `Layer.provide`:
123
+
124
+ ```ts example=kitchen-sink/src/request-policy.ts
125
+ import { Effect, Layer, Option } from "effect";
126
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
127
+ import { allowedOrigins, publicUrl } from "./config.js";
128
+
129
+ const loopback = ["localhost", "127.0.0.1"];
130
+
131
+ // Which Host and Origin a request may carry: not the library's concern, but every request of
132
+ // both ports passes it first. A Host that is neither loopback nor the public URL's is refused,
133
+ // against DNS rebinding. The web app signs in with a cookie, which a browser attaches to
134
+ // any page's request, so a foreign Origin must be refused before authentication reads it.
135
+ // That is why this is global router middleware (`global: true`): it runs before routing, so
136
+ // before every route's authentication. As a layer's own `middleware` it would run inside the
137
+ // authentication, too late.
138
+ export const requestPolicy = HttpRouter.middleware(
139
+ Effect.gen(function* () {
140
+ const hosts = [...loopback, new URL(yield* publicUrl).hostname];
141
+ const origins = yield* allowedOrigins;
142
+
143
+ return (httpEffect) =>
144
+ Effect.gen(function* () {
145
+ const request = yield* HttpServerRequest.HttpServerRequest;
146
+ const url = HttpServerRequest.toURL(request.modify({ url: request.originalUrl }));
147
+
148
+ if (Option.isNone(url) || !hosts.includes(url.value.hostname)) {
149
+ return HttpServerResponse.text("Host not allowed", { status: 403 });
150
+ }
151
+
152
+ const origin = request.headers["origin"];
153
+
154
+ // The request's own origin is that of a page served from this port directly. Behind a
155
+ // proxy ending TLS it is an `http:` one from Effect 4.0.6, whose `originalUrl` takes the
156
+ // scheme of the connection, whatever `X-Forwarded-Proto` says, so the public URL's
157
+ // origin is among `origins`.
158
+ if (origin !== undefined && origin !== url.value.origin && !origins.includes(origin)) {
159
+ return HttpServerResponse.text("Origin not allowed", { status: 403 });
160
+ }
161
+
162
+ return yield* httpEffect;
163
+ });
164
+ }),
165
+ { global: true },
166
+ );
99
167
 
100
- ```ts
101
- routes.pipe(Layer.provide(authenticate), Layer.provide(requestPolicy));
168
+ // CORS for the one cross-origin caller, a browser MCP client. `HttpRouter.cors` is global to
169
+ // the router too: it answers the preflight before any route's authentication, and adds its
170
+ // headers to refusals as well. An MCP endpoint's `allowedOrigins` is no CORS: it only refuses
171
+ // origins. The headers are those an MCP client sends and must be able to read. A client of an
172
+ // MCP revision with sessions sends its session's id on each request, reads it from the answer
173
+ // that opened the session, and ends the session with a `DELETE`.
174
+ export const cors = Layer.unwrap(
175
+ Effect.map(allowedOrigins, (origins) =>
176
+ HttpRouter.cors({
177
+ allowedOrigins: origins,
178
+ allowedMethods: ["POST", "DELETE"],
179
+ allowedHeaders: [
180
+ "Content-Type",
181
+ "Authorization",
182
+ "X-Workspace",
183
+ "MCP-Protocol-Version",
184
+ "MCP-Method",
185
+ "MCP-Name",
186
+ "Mcp-Session-Id",
187
+ ],
188
+ exposedHeaders: ["WWW-Authenticate", "MCP-Protocol-Version", "Mcp-Session-Id"],
189
+ }),
190
+ ),
191
+ );
102
192
  ```
103
193
 
104
- See [kitchen-sink/src/request-policy.ts](../examples/kitchen-sink/src/request-policy.ts),
105
- `publicRoutes` in [kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts) and
106
- [kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts).
107
-
108
- - **An MCP endpoint's `allowedOrigins` checks no `Host`.** It refuses only an unlisted `Origin`:
109
- a request sending none passes, whatever its `Host`. The example's policy refuses a `Host` that
110
- is neither loopback nor the public URL's, the check against DNS rebinding.
111
- - **The order decides whether the policy covers discovery.** Global middleware runs in the
112
- order it registers, a provider publishing a protected resource registers its discovery when
113
- it is built, and the layer provided last is built first. A policy merged beside the routes,
114
- with the provider provided around the merge, registers after the provider, and a foreign
115
- `Host` reads the discovery document.
194
+ See `publicRoutes` in [kitchen-sink/src/routes.ts](../examples/kitchen-sink/src/routes.ts);
195
+ [kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts)
196
+ asserts it.
116
197
 
117
198
  ## Refuse outside the router
118
199
 
119
- A caller the router never routes, such as a Node `upgrade` handler admitting a socket, holds a
120
- header and no request. Read the token with `bearerTokenOf`, verify it with the verifier the
121
- routes use, and answer a refusal with `refusalResponse` ([options](#api)):
122
-
123
- ```ts
124
- const admit = (authorization: string | undefined): Effect.Effect<User, Response> =>
125
- Effect.fromOption(
126
- Authentication.bearerTokenOf(authorization),
127
- () => new Action.Unauthenticated(),
128
- ).pipe(
129
- Effect.flatMap(verify),
130
- Effect.mapError((error) =>
131
- HttpServerResponse.toWeb(
132
- Authentication.refusalResponse(error, { protectedResource, authorization }),
133
- ),
134
- ),
135
- );
136
- ```
137
-
138
- - `protectedResource` is a value, not the Effect building it: where `layer` builds the resource
139
- from configuration, build the handler's from the same configuration.
200
+ For a caller the router never routes, such as a Node `upgrade` handler admitting a socket, read
201
+ the token with `Authentication.bearerTokenOf` and answer a refusal with
202
+ `Authentication.refusalResponse`, whose JSDoc holds the rest.