@cursor/july 0.1.91 → 0.1.92

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 (132) hide show
  1. package/AGENTS.md +4 -0
  2. package/dist/docs/404.html +3 -1
  3. package/dist/docs/ab.html +4 -2
  4. package/dist/docs/ab.md +338 -0
  5. package/dist/docs/building-with-agents.html +4 -2
  6. package/dist/docs/building-with-agents.md +124 -0
  7. package/dist/docs/concepts.html +4 -2
  8. package/dist/docs/concepts.md +174 -0
  9. package/dist/docs/deployment.html +4 -2
  10. package/dist/docs/deployment.md +464 -0
  11. package/dist/docs/evals.html +4 -2
  12. package/dist/docs/evals.md +481 -0
  13. package/dist/docs/example-agents/approval-buddy.html +4 -2
  14. package/dist/docs/example-agents/approval-buddy.md +266 -0
  15. package/dist/docs/example-agents/benny.html +4 -2
  16. package/dist/docs/example-agents/benny.md +182 -0
  17. package/dist/docs/example-agents/bugbot.html +4 -2
  18. package/dist/docs/example-agents/bugbot.md +229 -0
  19. package/dist/docs/example-agents/codebase-wiki.html +4 -2
  20. package/dist/docs/example-agents/codebase-wiki.md +170 -0
  21. package/dist/docs/example-agents/codeowners-review.html +4 -2
  22. package/dist/docs/example-agents/codeowners-review.md +192 -0
  23. package/dist/docs/example-agents/concierge.html +4 -2
  24. package/dist/docs/example-agents/concierge.md +201 -0
  25. package/dist/docs/example-agents/fsd.html +4 -2
  26. package/dist/docs/example-agents/fsd.md +329 -0
  27. package/dist/docs/example-agents/index.html +4 -2
  28. package/dist/docs/example-agents/index.md +102 -0
  29. package/dist/docs/example-agents/knowledge-base.html +4 -2
  30. package/dist/docs/example-agents/knowledge-base.md +168 -0
  31. package/dist/docs/example-agents/oncall.html +4 -2
  32. package/dist/docs/example-agents/oncall.md +212 -0
  33. package/dist/docs/example-agents/security-reviewer.html +4 -2
  34. package/dist/docs/example-agents/security-reviewer.md +265 -0
  35. package/dist/docs/example-agents/slack-agent.html +4 -2
  36. package/dist/docs/example-agents/slack-agent.md +142 -0
  37. package/dist/docs/example-agents/weather-agent.html +4 -2
  38. package/dist/docs/example-agents/weather-agent.md +296 -0
  39. package/dist/docs/guides/agent-to-agent.html +4 -2
  40. package/dist/docs/guides/agent-to-agent.md +113 -0
  41. package/dist/docs/guides/cloud-runtime.html +4 -2
  42. package/dist/docs/guides/cloud-runtime.md +131 -0
  43. package/dist/docs/guides/convert-automation.html +4 -2
  44. package/dist/docs/guides/convert-automation.md +171 -0
  45. package/dist/docs/guides/github.html +4 -2
  46. package/dist/docs/guides/github.md +287 -0
  47. package/dist/docs/guides/human-in-the-loop.html +4 -2
  48. package/dist/docs/guides/human-in-the-loop.md +126 -0
  49. package/dist/docs/guides/mcp-oauth.html +4 -2
  50. package/dist/docs/guides/mcp-oauth.md +159 -0
  51. package/dist/docs/guides/opentelemetry.html +4 -2
  52. package/dist/docs/guides/opentelemetry.md +209 -0
  53. package/dist/docs/guides/slack.html +4 -2
  54. package/dist/docs/guides/slack.md +337 -0
  55. package/dist/docs/guides/webhooks.html +4 -2
  56. package/dist/docs/guides/webhooks.md +463 -0
  57. package/dist/docs/hillclimbing.html +4 -2
  58. package/dist/docs/hillclimbing.md +88 -0
  59. package/dist/docs/index.html +4 -2
  60. package/dist/docs/index.md +179 -0
  61. package/dist/docs/llms-full.txt +11518 -0
  62. package/dist/docs/llms.txt +75 -0
  63. package/dist/docs/quickstart.html +4 -2
  64. package/dist/docs/quickstart.md +364 -0
  65. package/dist/docs/reference/agent-config.html +4 -2
  66. package/dist/docs/reference/agent-config.md +256 -0
  67. package/dist/docs/reference/artifacts.html +4 -2
  68. package/dist/docs/reference/artifacts.md +112 -0
  69. package/dist/docs/reference/channels.html +4 -2
  70. package/dist/docs/reference/channels.md +255 -0
  71. package/dist/docs/reference/cli.html +4 -2
  72. package/dist/docs/reference/cli.md +957 -0
  73. package/dist/docs/reference/connections.html +4 -2
  74. package/dist/docs/reference/connections.md +268 -0
  75. package/dist/docs/reference/hooks.html +4 -2
  76. package/dist/docs/reference/hooks.md +102 -0
  77. package/dist/docs/reference/http-api.html +4 -2
  78. package/dist/docs/reference/http-api.md +267 -0
  79. package/dist/docs/reference/instructions.html +4 -2
  80. package/dist/docs/reference/instructions.md +74 -0
  81. package/dist/docs/reference/playground.html +4 -2
  82. package/dist/docs/reference/playground.md +62 -0
  83. package/dist/docs/reference/project-layout.html +4 -2
  84. package/dist/docs/reference/project-layout.md +107 -0
  85. package/dist/docs/reference/prompt.html +4 -2
  86. package/dist/docs/reference/prompt.md +42 -0
  87. package/dist/docs/reference/schedules.html +4 -2
  88. package/dist/docs/reference/schedules.md +215 -0
  89. package/dist/docs/reference/sessions.html +4 -2
  90. package/dist/docs/reference/sessions.md +170 -0
  91. package/dist/docs/reference/skills.html +4 -2
  92. package/dist/docs/reference/skills.md +83 -0
  93. package/dist/docs/reference/subagents.html +4 -2
  94. package/dist/docs/reference/subagents.md +71 -0
  95. package/dist/docs/reference/tools.html +4 -2
  96. package/dist/docs/reference/tools.md +298 -0
  97. package/dist/docs/scaffolding-agents.html +4 -2
  98. package/dist/docs/scaffolding-agents.md +130 -0
  99. package/dist/docs/storage.html +4 -2
  100. package/dist/docs/storage.md +219 -0
  101. package/dist/docs/templates/agentic-owners.html +4 -2
  102. package/dist/docs/templates/agentic-owners.md +92 -0
  103. package/dist/docs/templates/demo.html +4 -2
  104. package/dist/docs/templates/demo.md +79 -0
  105. package/dist/docs/templates/pr-autofixer.html +4 -2
  106. package/dist/docs/templates/pr-autofixer.md +131 -0
  107. package/dist/docs/templates/security-reviewer.html +4 -2
  108. package/dist/docs/templates/security-reviewer.md +84 -0
  109. package/dist/docs/templates/triage.html +4 -2
  110. package/dist/docs/templates/triage.md +98 -0
  111. package/dist/docs/troubleshooting.html +4 -2
  112. package/dist/docs/troubleshooting.md +111 -0
  113. package/dist/internal/init-scaffold.d.ts.map +1 -1
  114. package/dist/internal/init-scaffold.js +1 -0
  115. package/dist/internal/playground/static.d.ts.map +1 -1
  116. package/dist/internal/playground/static.js +2 -0
  117. package/dist/internal/session-engine.d.ts.map +1 -1
  118. package/dist/internal/session-engine.js +3 -1
  119. package/package.json +2 -2
  120. package/skills/ab/SKILL.md +3 -0
  121. package/skills/create-agent/SKILL.md +3 -0
  122. package/skills/debug/SKILL.md +3 -0
  123. package/skills/evals/SKILL.md +3 -0
  124. package/skills/framework-map/SKILL.md +3 -0
  125. package/skills/github/SKILL.md +3 -0
  126. package/skills/hillclimb/SKILL.md +3 -0
  127. package/skills/mcp-auth/SKILL.md +3 -0
  128. package/skills/otel/SKILL.md +3 -0
  129. package/skills/setup-slack/SKILL.md +3 -0
  130. package/src/internal/init-scaffold.ts +1 -0
  131. package/src/internal/playground/static.ts +2 -0
  132. package/src/internal/session-engine.ts +4 -6
@@ -0,0 +1,463 @@
1
+ # Webhooks and custom channels
2
+
3
+ A custom channel gives the agent its own HTTP surface. You get routes
4
+ with validated payloads, sessions keyed to something in your domain (a
5
+ thread, a ticket, a PR), and replies delivered back to the caller. The
6
+ [Slack](/docs/guides/slack.md) and [GitHub](/docs/guides/github.md) packs build on this
7
+ mechanism. This page is the mechanism itself.
8
+
9
+ ## What you already have
10
+
11
+ The built-in HTTP channel is always mounted (under `/<slug>` in the
12
+ default multi-agent layout). `POST /v1/session` starts a conversation,
13
+ `POST /v1/session/:id` follows up, and `GET /v1/session/:id/stream`
14
+ streams NDJSON events, plus sessions, approvals, and tool routes. The
15
+ full list is in the [HTTP API reference](/docs/reference/http-api.md).
16
+
17
+ Write a custom channel when that shape doesn't fit: a webhook with its
18
+ own payload contract, a surface that keys sessions by a domain id, or a
19
+ flow that does host-side work before (or instead of) a model turn.
20
+
21
+ ## Define a channel
22
+
23
+ Author `agent/channels/<id>.ts` with `defineChannel`. The filename is
24
+ the channel id, and routes mount under `/v1/channels/<id>`:
25
+
26
+ ```ts
27
+ import { defineChannel, POST } from "@cursor/july/channels";
28
+ import { z } from "zod";
29
+
30
+ export default defineChannel({
31
+ routes: [
32
+ POST("/message", {
33
+ description: "Enqueue a chat turn on this channel",
34
+ bodySchema: z.object({
35
+ message: z.string(),
36
+ thread: z.string().optional(),
37
+ }),
38
+ handler: async (_req, { send, body }) => {
39
+ const session = await send(body.message, {
40
+ // stable key: same thread → same durable session
41
+ continuationToken: body.thread,
42
+ });
43
+ return Response.json({ sessionId: session.id });
44
+ },
45
+ }),
46
+ ],
47
+ events: {
48
+ "message.completed"(event, channel, ctx) {
49
+ // deliver the reply back to the surface that owns this channel:
50
+ // post to a webhook, reply in a thread, update a ticket, …
51
+ },
52
+ },
53
+ });
54
+ ```
55
+
56
+ ```bash
57
+ curl -X POST http://127.0.0.1:3000/<slug>/v1/channels/<id>/message \
58
+ -H 'content-type: application/json' \
59
+ -d '{"message":"hello","thread":"ticket-42"}'
60
+ ```
61
+
62
+ ## Schemas are Zod, and required
63
+
64
+ `GET` routes require a Zod `querySchema`. `POST`, `PUT`, and `PATCH`
65
+ require a Zod `bodySchema`. Compile-time checks enforce this, so plain
66
+ JSON Schema objects won't type-check. Use `z.object({})` or
67
+ `z.unknown()` when a surface is intentionally open. The host validates
68
+ before your handler runs, handlers receive typed `args.body` and
69
+ `args.query`, and empty POST bodies are coerced to `{}` first.
70
+
71
+ Declared schemas also feed `GET /v1/info`, which adds two things to the
72
+ playground: a **Try** button on every route, and matching **slash
73
+ commands** in the composer (a channel `drive` route becomes `/drive …`).
74
+
75
+ ## What a handler receives
76
+
77
+ Handlers get the Fetch `Request` plus an args object:
78
+
79
+ | Helper | What it does |
80
+ | --- | --- |
81
+ | `send(message, options?)` | Run a model turn on this channel. `continuationToken` keys the durable session; options can seed `workspaceFiles`, attach `cloud` repos, or override `auth`. |
82
+ | `getSession(sessionId)` | Look up an existing session |
83
+ | `receive(channel, input)` | Hand off to another channel (schedules use this) |
84
+ | `callTool(name, input, opts?)` | Run an authored server tool with no model turn ([Tools](/docs/reference/tools.md#call-a-tool-without-a-model-turn)) |
85
+ | `body`, `query`, `params` | Validated payloads and path params |
86
+ | `auth` | The `AuthContext` the route's auth chain resolved |
87
+ | `requestIp` | The TCP peer address |
88
+ | `host` | Shared host services: `host.mcp`, `host.github`, `host.slack`, `host.reminders` |
89
+ | `waitUntil(promise)` | Background work that outlives the response |
90
+
91
+ Channel `state` declares the starting per-session adapter state. It
92
+ persists across events, and event handlers receive it on
93
+ `channel.state`.
94
+
95
+ ## Respond fast, work in the background
96
+
97
+ Webhook senders time out quickly. GitHub gives you about ten seconds.
98
+ For slow work, ACK immediately and continue in the background:
99
+
100
+ ```ts
101
+ handler: async (_req, { send, waitUntil, body }) => {
102
+ waitUntil(
103
+ send(`Process incoming report: ${body.url}`, {
104
+ continuationToken: `report:${body.id}`,
105
+ })
106
+ );
107
+ return Response.json({ accepted: true }, { status: 202 });
108
+ },
109
+ ```
110
+
111
+ ## Prepare on the host, then hand off
112
+
113
+ The strongest channel pattern: do the deterministic setup in the
114
+ handler, then hand the model prepared evidence. Fetch the PR with
115
+ `callTool`, seed the files it needs, and make the prompt about judgment
116
+ rather than about finding things:
117
+
118
+ ```ts
119
+ handler: async (_req, { callTool, send, body }) => {
120
+ const prep = await callTool("prepare_pr", { pr: body.pr });
121
+ if (prep.isError) {
122
+ return Response.json({ ok: false, error: prep.result }, { status: 502 });
123
+ }
124
+ const session = await send("Review the prepared PR under pr/.", {
125
+ continuationToken: `pr:${body.pr}`,
126
+ });
127
+ return Response.json({ sessionId: session.id });
128
+ },
129
+ ```
130
+
131
+ This host-prep shape is the change with the largest effect on latency
132
+ and quality. [Hillclimbing](/docs/hillclimbing.md) lists it first.
133
+
134
+ ## Deliver replies back out
135
+
136
+ The `events` map subscribes the channel to stream events for the
137
+ sessions it owns. Typical wiring: `message.completed` posts the
138
+ assistant text back to the caller's surface, and `turn.failed` posts an
139
+ error notice. The full vocabulary is in
140
+ [Sessions and streaming](/docs/reference/sessions.md#which-events-can-i-stream).
141
+
142
+ ## Auth: loopback by default, on purpose
143
+
144
+ Every route runs an auth-policy chain (the channel's `auth` array). The
145
+ default is `[localDevStrict()]`: direct loopback callers only. Requests
146
+ carrying proxy-forwarding headers (`X-Forwarded-For`, `X-Real-IP`,
147
+ `Forwarded`, `X-Forwarded-Host`) are rejected, and a loopback `Host`
148
+ header is required. So a tunnel, a same-host reverse proxy, or a
149
+ DNS-rebinding page can't silently re-expose the route.
150
+
151
+ Before real traffic, author auth explicitly:
152
+
153
+ ```ts
154
+ import { bearerAuth, defineChannel, localDevStrict } from "@cursor/july/channels";
155
+
156
+ export default defineChannel({
157
+ auth: [localDevStrict(), bearerAuth(process.env.WEBHOOK_TOKEN ?? "")],
158
+ routes: [/* … */],
159
+ });
160
+ ```
161
+
162
+ The built-in policies are `localDevStrict()` (the default),
163
+ `localDev()`, `loopbackOnly()`, `bearerAuth(tokenOrVerify)`, and
164
+ `allowAll()`, and `publicEndpoint()`. Any
165
+ `(request, info) => AuthContext | null` function composes with them.
166
+
167
+ The details live in
168
+ [Channels](/docs/reference/channels.md#auth-policies).
169
+
170
+ Server-level flags interact with channel auth. `--bearer-token <secret>`
171
+ swaps the default for `bearerAuth` on every channel that doesn't author
172
+ its own chain, and `--allow-anonymous` swaps it for `allowAll()`
173
+ (trusted networks only). Authored `auth` arrays always win over both.
174
+
175
+ ## Receive signed webhooks on managed hosting
176
+
177
+ Cursor-managed deployments expose a stable alias URL. External callers
178
+ normally send `X-Agent-Alias-Token` on every request. Webhook providers
179
+ often cannot set it.
180
+
181
+ Add `publicEndpoint()` to a custom channel that verifies its own provider
182
+ signature. Only that channel path skips the alias token. Session and tool
183
+ routes stay private. See [Deployment](/docs/deployment.md#use-the-hosted-agent).
184
+
185
+ ## Example: Linear as the control plane
186
+
187
+ Everything above composes into a working ticket-driven agent. This
188
+ example wires Linear to the agent: new issues and comments start or
189
+ resume sessions, and replies land back on the issue as comments. The
190
+ same shape works for any tracker with signed webhooks.
191
+
192
+ Create the webhook in Linear under Settings → API → "New webhook",
193
+ pointed at `https://<your-host>/<slug>/v1/channels/linear`, and copy
194
+ the signing secret. Linear requires a public HTTPS URL, so use a
195
+ tunnel during local development or test with signed fixtures (below).
196
+ Set three environment variables:
197
+
198
+ ```bash
199
+ LINEAR_WEBHOOK_SECRET=lin_wh_... # the webhook's signing secret
200
+ LINEAR_API_KEY=lin_api_... # posts replies as comments
201
+ LINEAR_AGENT_USER_ID=... # the API key's user: query { viewer { id } }
202
+ ```
203
+
204
+ `LINEAR_AGENT_USER_ID` matters: replies posted with the API key
205
+ trigger the Comment webhook again, so the channel must recognize and
206
+ skip its own comments. Without the guard, every reply starts another
207
+ turn.
208
+
209
+ Author `agent/channels/linear.ts`:
210
+
211
+ ```ts
212
+ import { Buffer } from "node:buffer";
213
+ import { createHmac, timingSafeEqual } from "node:crypto";
214
+ import { defineChannel, POST, publicEndpoint } from "@cursor/july/channels";
215
+ import { z } from "zod";
216
+
217
+ const secret = process.env.LINEAR_WEBHOOK_SECRET ?? "";
218
+ const apiKey = process.env.LINEAR_API_KEY ?? "";
219
+ const agentUserId = process.env.LINEAR_AGENT_USER_ID ?? "";
220
+
221
+ interface LinearWebhook {
222
+ action: string;
223
+ type: string;
224
+ url?: string;
225
+ webhookTimestamp: number;
226
+ data: {
227
+ id: string;
228
+ title?: string;
229
+ description?: string;
230
+ body?: string;
231
+ issueId?: string;
232
+ userId?: string;
233
+ };
234
+ }
235
+
236
+ // Linear signs the raw body: hex HMAC-SHA256 in `Linear-Signature`.
237
+ function verified(rawBody: string, header: string | null): boolean {
238
+ if (secret === "" || header === null) {
239
+ return false;
240
+ }
241
+ const expected = createHmac("sha256", secret).update(rawBody).digest();
242
+ const received = Buffer.from(header, "hex");
243
+ return (
244
+ received.length === expected.length && timingSafeEqual(received, expected)
245
+ );
246
+ }
247
+
248
+ async function postComment(issueId: string, body: string): Promise<void> {
249
+ const response = await fetch("https://api.linear.app/graphql", {
250
+ method: "POST",
251
+ headers: { "content-type": "application/json", authorization: apiKey },
252
+ body: JSON.stringify({
253
+ query:
254
+ "mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }",
255
+ variables: { input: { issueId, body } },
256
+ }),
257
+ });
258
+ if (!response.ok) {
259
+ throw new Error(`commentCreate failed: ${response.status}`);
260
+ }
261
+ }
262
+
263
+ function issueIdFromToken(token: string | null): string | null {
264
+ if (token === null || !token.startsWith("linear:")) {
265
+ return null;
266
+ }
267
+ return token.slice("linear:".length);
268
+ }
269
+
270
+ export default defineChannel({
271
+ auth: [publicEndpoint()],
272
+ routes: [
273
+ POST("/", {
274
+ description: "Linear webhook ingress",
275
+ // The payload shape varies by `Linear-Event`; parse after verifying.
276
+ bodySchema: z.unknown(),
277
+ handler: async (request, { send, waitUntil }) => {
278
+ const rawBody = await request.text();
279
+ if (!verified(rawBody, request.headers.get("linear-signature"))) {
280
+ return Response.json({ ok: false }, { status: 401 });
281
+ }
282
+ const event = JSON.parse(rawBody) as LinearWebhook;
283
+ // Reject stale deliveries to guard against replay.
284
+ if (Math.abs(Date.now() - event.webhookTimestamp) > 60_000) {
285
+ return Response.json({ ok: false }, { status: 401 });
286
+ }
287
+ // Skip the agent's own comments so replies don't re-trigger it.
288
+ if (event.type === "Comment" && event.data.userId === agentUserId) {
289
+ return Response.json({ ok: true });
290
+ }
291
+
292
+ // New issue → new session. New comment → follow-up on the same
293
+ // session, keyed by issue id through the continuation token.
294
+ let issueId: string | undefined;
295
+ let message: string | undefined;
296
+ if (event.type === "Issue" && event.action === "create") {
297
+ issueId = event.data.id;
298
+ message = `New Linear issue: ${event.data.title}\n\n${
299
+ event.data.description ?? ""
300
+ }\n${event.url ?? ""}`;
301
+ } else if (event.type === "Comment" && event.action === "create") {
302
+ issueId = event.data.issueId;
303
+ message = event.data.body;
304
+ }
305
+ if (issueId === undefined || message === undefined) {
306
+ return Response.json({ ok: true });
307
+ }
308
+
309
+ // Linear retries on any non-200 and times out after five
310
+ // seconds: ACK now, run the turn in the background.
311
+ waitUntil(send(message, { continuationToken: `linear:${issueId}` }));
312
+ return Response.json({ ok: true });
313
+ },
314
+ }),
315
+ ],
316
+ events: {
317
+ async "message.completed"(event, channel) {
318
+ if (event.data.finishReason === "tool_call" || event.data.text === "") {
319
+ return;
320
+ }
321
+ const issueId = issueIdFromToken(channel.continuationToken);
322
+ if (issueId !== null) {
323
+ await postComment(issueId, event.data.text);
324
+ }
325
+ },
326
+ async "turn.failed"(event, channel) {
327
+ const issueId = issueIdFromToken(channel.continuationToken);
328
+ if (issueId !== null) {
329
+ await postComment(issueId, `Turn failed: ${event.data.message}`);
330
+ }
331
+ },
332
+ },
333
+ });
334
+ ```
335
+
336
+ The channel handles ingress and reply delivery deterministically. To
337
+ let the model read and update Linear during the turn (search related
338
+ issues, change state, assign), add the Linear MCP connection alongside
339
+ it:
340
+
341
+ ```ts
342
+ // agent/mcp-connections/linear.ts
343
+ import { defineConnection } from "@cursor/july/connections";
344
+
345
+ // Uses the signed-in Cursor account's Linear connector.
346
+ export default defineConnection({ cursorAccount: true, servers: ["Linear"] });
347
+ ```
348
+
349
+ See [MCP connections](/docs/reference/connections.md) for the direct
350
+ `https://mcp.linear.app/mcp` form when the host isn't signed in to
351
+ Cursor.
352
+
353
+ ### Production Linear agents: expiring OAuth tokens
354
+
355
+ A plain API key fits a personal integration. A production Linear agent
356
+ is an OAuth application: installing it in a workspace mints an access
357
+ token with an expiry plus a refresh token. The serve host can be
358
+ replaced at any time, so the pair can't live in process memory.
359
+ Persist it in [`host.kv`](/docs/storage.md#author-kv-ctx-host-kv) instead.
360
+ Route handlers receive `host` in their args, and event handlers get the
361
+ same services on `ctx`.
362
+
363
+ Two prerequisites:
364
+
365
+ - Keep the OAuth client id and client secret in
366
+ [deployment secrets](/docs/deployment.md). They're static, so
367
+ deploy-time env vars fit them.
368
+ - Back kv with durable storage. On Cursor-managed hosting, use
369
+ `cursorHostedStorage` in `agent/storage.ts`
370
+ ([Storage](/docs/storage.md)). Without it, kv falls back to local disk
371
+ and a host replacement drops the tokens.
372
+
373
+ Save the pair when the install flow completes, then refresh on demand:
374
+
375
+ ```ts
376
+ import type { HostKvApi } from "@cursor/july/kv";
377
+
378
+ interface LinearTokens {
379
+ accessToken: string;
380
+ refreshToken: string;
381
+ expiresAtMs: number;
382
+ }
383
+
384
+ const TOKENS_KEY = "linear/oauth-tokens";
385
+
386
+ export async function linearAccessToken(kv: HostKvApi): Promise<string> {
387
+ const tokens = (await kv.get(TOKENS_KEY)) as LinearTokens | undefined;
388
+ if (tokens === undefined) {
389
+ throw new Error("No Linear tokens stored. Complete the install flow first.");
390
+ }
391
+ if (Date.now() < tokens.expiresAtMs - 60_000) {
392
+ return tokens.accessToken;
393
+ }
394
+ const response = await fetch("https://api.linear.app/oauth/token", {
395
+ method: "POST",
396
+ headers: { "content-type": "application/x-www-form-urlencoded" },
397
+ body: new URLSearchParams({
398
+ grant_type: "refresh_token",
399
+ refresh_token: tokens.refreshToken,
400
+ client_id: process.env.LINEAR_CLIENT_ID ?? "",
401
+ client_secret: process.env.LINEAR_CLIENT_SECRET ?? "",
402
+ }),
403
+ });
404
+ if (!response.ok) {
405
+ throw new Error(`Linear token refresh failed: ${response.status}`);
406
+ }
407
+ const next = (await response.json()) as {
408
+ access_token: string;
409
+ refresh_token?: string;
410
+ expires_in: number;
411
+ };
412
+ const rotated: LinearTokens = {
413
+ accessToken: next.access_token,
414
+ refreshToken: next.refresh_token ?? tokens.refreshToken,
415
+ expiresAtMs: Date.now() + next.expires_in * 1000,
416
+ };
417
+ await kv.put(TOKENS_KEY, rotated);
418
+ return rotated.accessToken;
419
+ }
420
+ ```
421
+
422
+ Then authorize GraphQL calls with `Bearer ${await
423
+ linearAccessToken(host.kv)}` in place of the static `apiKey` above.
424
+ Writes to `host.kv` await the durable sink and propagate errors, so a
425
+ failed save surfaces instead of silently losing the rotated refresh
426
+ token.
427
+
428
+ To test without a public URL, save a payload from the webhook's
429
+ delivery log (or the sample in
430
+ [Linear's webhook docs](https://linear.app/developers/webhooks)) under
431
+ `fixtures/`, refresh its `webhookTimestamp`, and sign it yourself:
432
+
433
+ ```bash
434
+ SIG=$(node -e 'const {createHmac}=require("node:crypto");const fs=require("node:fs");
435
+ process.stdout.write(createHmac("sha256",process.env.LINEAR_WEBHOOK_SECRET)
436
+ .update(fs.readFileSync("fixtures/issue-create.json")).digest("hex"))')
437
+ curl -X POST http://127.0.0.1:3000/<slug>/v1/channels/linear/ \
438
+ -H 'content-type: application/json' -H "linear-signature: $SIG" \
439
+ --data-binary @fixtures/issue-create.json
440
+ ```
441
+
442
+ ## Test a channel
443
+
444
+ Start with curl and saved payloads under `fixtures/`. The playground's
445
+ **Try** modal covers manual probes. It remembers your last body per
446
+ endpoint, has Copy curl, and opens the created session on a successful
447
+ Try. For regression coverage, drive the same behavior through an eval,
448
+ or keep channel logic deterministic in `agent/lib/` and unit-test it
449
+ there. When something looks wrong, read the session's `events.ndjson`.
450
+ The stream is the record of what happened.
451
+
452
+ For GitHub specifically, don't hand-roll fixtures.
453
+ `agent-sdk github replay` synthesizes real-shaped, signed payloads
454
+ from any PR you can read. See the [GitHub guide](/docs/guides/github.md).
455
+
456
+ ## What's next
457
+
458
+ Continue with these pages:
459
+
460
+ - [Channels reference](/docs/reference/channels.md): the full authoring API
461
+ - [GitHub](/docs/guides/github.md) and [Slack](/docs/guides/slack.md): the packaged channels
462
+ - [Sessions and streaming](/docs/reference/sessions.md): events your
463
+ channel can subscribe to