@keystrokehq/cli 0.1.15 → 0.1.16
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.
- package/dist/index.mjs +7 -4
- package/dist/index.mjs.map +1 -1
- package/dist/skills-bundle/_AGENTS.mcp.md +10 -3
- package/dist/skills-bundle/_AGENTS.md +61 -70
- package/dist/skills-bundle/skills/keystroke-actions/SKILL.md +60 -12
- package/dist/skills-bundle/skills/keystroke-actions/references/catalog-and-imports.md +32 -3
- package/dist/skills-bundle/skills/keystroke-agents/SKILL.md +50 -8
- package/dist/skills-bundle/skills/keystroke-agents/references/models.md +11 -13
- package/dist/skills-bundle/skills/keystroke-agents/references/tools-mcp-codemode.md +45 -3
- package/dist/skills-bundle/skills/keystroke-agents/references/workflows-and-testing.md +1 -1
- package/dist/skills-bundle/skills/keystroke-apps/SKILL.md +26 -13
- package/dist/skills-bundle/skills/keystroke-apps/references/cli-and-catalog.md +47 -16
- package/dist/skills-bundle/skills/keystroke-channels/SKILL.md +66 -0
- package/dist/skills-bundle/skills/keystroke-channels/references/slack-setup.md +41 -0
- package/dist/skills-bundle/skills/keystroke-cli/SKILL.md +41 -93
- package/dist/skills-bundle/skills/keystroke-deploy/SKILL.md +10 -9
- package/dist/skills-bundle/skills/keystroke-deploy/references/build-and-full-deploy.md +3 -1
- package/dist/skills-bundle/skills/keystroke-deploy/references/filtered-deploy.md +3 -2
- package/dist/skills-bundle/skills/keystroke-deploy/references/wip-ignore.md +5 -2
- package/dist/skills-bundle/skills/keystroke-files/SKILL.md +12 -4
- package/dist/skills-bundle/skills/keystroke-skills/SKILL.md +7 -2
- package/dist/skills-bundle/skills/keystroke-triggers/SKILL.md +30 -17
- package/dist/skills-bundle/skills/keystroke-workflows/SKILL.md +27 -12
- package/dist/skills-bundle/skills/keystroke-workflows/references/authoring.md +116 -4
- package/dist/skills-bundle/skills/keystroke-workflows/references/testing.md +17 -9
- package/dist/templates/hello-world/README.md +19 -8
- package/dist/templates/hello-world/src/workflows/greeting.test.ts +1 -1
- package/package.json +2 -2
- package/dist/skills-bundle/skills/keystroke-cli/references/api-targets.md +0 -87
- package/dist/skills-bundle/skills/keystroke-gateways/SKILL.md +0 -43
- package/dist/skills-bundle/skills/keystroke-gateways/references/slack-setup.md +0 -27
|
@@ -28,10 +28,15 @@ At runtime → `/workspace/agent/skills/support/`.
|
|
|
28
28
|
|
|
29
29
|
## SKILL.md
|
|
30
30
|
|
|
31
|
-
Follow [Agent Skills](https://agentskills.io/specification): YAML frontmatter with `name` (matches folder) and `description
|
|
31
|
+
Follow [Agent Skills](https://agentskills.io/specification): YAML frontmatter with `name` (matches the folder) and `description` are the required fields; everything else (e.g. `metadata`) is optional. Keep the body short; put detail in `references/`.
|
|
32
|
+
|
|
33
|
+
## Two kinds of skills (don't confuse them)
|
|
34
|
+
|
|
35
|
+
- **`src/skills/`** — *project* Agent Skills attached to your agents via `skills: [...]`. They deploy with your project; inspect deployed ones with `keystroke skill list`.
|
|
36
|
+
- **`.agents/skills/`** — the *bundled coding-agent* guides (like this one) that `keystroke init` scaffolds. Refresh them to the current CLI version with `keystroke skills sync` (note the plural `skills`).
|
|
32
37
|
|
|
33
38
|
## External registries
|
|
34
39
|
|
|
35
|
-
Browse [skills.sh](https://skills.sh) — copy into `src/skills/{
|
|
40
|
+
Browse [skills.sh](https://skills.sh) — copy into `src/skills/{name}/` and fix `name` to match the folder.
|
|
36
41
|
|
|
37
42
|
Related: [agents](.agents/skills/keystroke-agents/SKILL.md), [files](.agents/skills/keystroke-files/SKILL.md).
|
|
@@ -7,18 +7,18 @@ metadata:
|
|
|
7
7
|
|
|
8
8
|
# Triggers
|
|
9
9
|
|
|
10
|
-
Triggers **attach** a source to a workflow. No business logic here — only schedule, endpoint, validation, and filters.
|
|
10
|
+
Triggers **attach** a source to a target (a workflow or an agent). No business logic here — only schedule, endpoint, validation, and filters.
|
|
11
11
|
|
|
12
|
-
Attachment id: `{
|
|
12
|
+
Attachment id: `{sourceSlug}:{targetSlug}` (e.g. `signup:signup-pipeline`), where the suffix is the workflow's (or agent's) `slug`.
|
|
13
13
|
|
|
14
14
|
## Cron
|
|
15
15
|
|
|
16
16
|
```ts
|
|
17
|
-
import { defineCronSource } from "@keystrokehq/trigger";
|
|
17
|
+
import { defineCronSource } from "@keystrokehq/keystroke/trigger";
|
|
18
18
|
import workflow from "../workflows/morning-check";
|
|
19
19
|
|
|
20
20
|
export default defineCronSource({
|
|
21
|
-
|
|
21
|
+
slug: "morning-check",
|
|
22
22
|
schedule: "0 9 * * *",
|
|
23
23
|
}).attach({ workflow });
|
|
24
24
|
```
|
|
@@ -26,12 +26,12 @@ export default defineCronSource({
|
|
|
26
26
|
## Webhook
|
|
27
27
|
|
|
28
28
|
```ts
|
|
29
|
-
import { defineWebhookSource } from "@keystrokehq/trigger";
|
|
29
|
+
import { defineWebhookSource } from "@keystrokehq/keystroke/trigger";
|
|
30
30
|
import { z } from "zod";
|
|
31
31
|
import workflow from "../workflows/signup-pipeline";
|
|
32
32
|
|
|
33
33
|
export default defineWebhookSource({
|
|
34
|
-
|
|
34
|
+
slug: "signup",
|
|
35
35
|
endpoint: "signup",
|
|
36
36
|
request: z.object({
|
|
37
37
|
name: z.string().trim().min(1),
|
|
@@ -47,20 +47,22 @@ Use optional Zod `filter` for extra constraints beyond `request`:
|
|
|
47
47
|
filter: z.object({ type: z.literal("invoice.paid") }),
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
**Webhooks ack asynchronously.** The POST returns immediately — `202 { runId }` when a binding matches, or `{ ok: true, skipped: true }` when nothing does. The workflow runs in the background; its output is **not** returned in the HTTP response (there's no "respond to webhook"). To return data to the caller, make an outbound call from the workflow, or have the caller poll the run via the runs API / `keystroke workflow runs get`.
|
|
51
|
+
|
|
50
52
|
### Shared endpoint (e.g. Stripe)
|
|
51
53
|
|
|
52
|
-
Multiple trigger files can use the same `endpoint` — one URL `POST /triggers/{endpoint}`, each with its own `
|
|
54
|
+
Multiple trigger files can use the same `endpoint` — one URL `POST /triggers/{endpoint}`, each with its own `slug`, `request`, `filter`, and `transform`. Unmatched payloads return `{ ok: true, skipped: true }`.
|
|
53
55
|
|
|
54
56
|
```ts
|
|
55
57
|
// src/triggers/stripe-invoice-paid.ts
|
|
56
58
|
export default defineWebhookSource({
|
|
57
|
-
|
|
59
|
+
slug: "stripe-invoice-paid",
|
|
58
60
|
endpoint: "stripe",
|
|
59
61
|
request: z.object({ type: z.string(), data: z.object({ id: z.string() }) }),
|
|
60
62
|
filter: z.object({ type: z.literal("invoice.paid") }),
|
|
61
63
|
}).attach({ workflow: invoicePaidWorkflow, transform: (p) => ({ invoiceId: p.data.id }) });
|
|
62
64
|
|
|
63
|
-
// src/triggers/stripe-subscription-deleted.ts — same endpoint, different
|
|
65
|
+
// src/triggers/stripe-subscription-deleted.ts — same endpoint, different slug/schema/filter
|
|
64
66
|
```
|
|
65
67
|
|
|
66
68
|
List or inspect all triggers on an endpoint:
|
|
@@ -71,21 +73,23 @@ keystroke trigger get stripe # same rows as list --endpoint (shared rou
|
|
|
71
73
|
keystroke trigger url stripe # one webhook URL for the route
|
|
72
74
|
```
|
|
73
75
|
|
|
74
|
-
Use each trigger's `
|
|
76
|
+
Use each trigger's `slug` for `trigger get` / run history (`keystroke trigger runs list stripe-invoice-paid:…`).
|
|
75
77
|
|
|
76
78
|
## Poll
|
|
77
79
|
|
|
78
80
|
```ts
|
|
79
|
-
import { definePollSource } from "@keystrokehq/trigger";
|
|
81
|
+
import { definePollSource } from "@keystrokehq/keystroke/trigger";
|
|
80
82
|
import workflow from "../workflows/new-inbox";
|
|
81
83
|
|
|
82
84
|
export default definePollSource({
|
|
83
|
-
|
|
85
|
+
slug: "new-inbox",
|
|
84
86
|
schedule: "*/5 * * * *",
|
|
85
87
|
run: () => ({ emails: [] }),
|
|
86
88
|
}).attach({ workflow });
|
|
87
89
|
```
|
|
88
90
|
|
|
91
|
+
Poll filtering uses a **function predicate** (not a Zod schema like webhooks) — either `.filter((result) => …)` chained on the source, or a `filter:` option. Returning falsy skips the tick. Group polls that should run together with `definePollSource({ id: "...", … })`.
|
|
92
|
+
|
|
89
93
|
### Ephemeral poll (agents)
|
|
90
94
|
|
|
91
95
|
Agents can register a scheduled codemode script with `set_trigger`:
|
|
@@ -93,7 +97,7 @@ Agents can register a scheduled codemode script with `set_trigger`:
|
|
|
93
97
|
```ts
|
|
94
98
|
set_trigger({
|
|
95
99
|
kind: "poll",
|
|
96
|
-
|
|
100
|
+
slug: "inbox",
|
|
97
101
|
schedule: "*/5 * * * *",
|
|
98
102
|
code: [
|
|
99
103
|
'const emails = await tools["list-emails"]({ query: "is:unread" });',
|
|
@@ -101,14 +105,22 @@ set_trigger({
|
|
|
101
105
|
"console.log(JSON.stringify({ count: emails.items.length, items: emails.items }));",
|
|
102
106
|
].join("\n"),
|
|
103
107
|
prompt: "You have {{payload.count}} unread emails.",
|
|
108
|
+
lifecycle: { maxExecutions: 10 }, // optional: cap runs (also `until`)
|
|
104
109
|
});
|
|
105
110
|
```
|
|
106
111
|
|
|
107
112
|
- Write the script the same way you would for codemode (`bash` + `js-exec`).
|
|
108
113
|
- `console.log(JSON.stringify(result))` when there is work to do.
|
|
109
|
-
- Log nothing (or `null`) to skip — skipped ticks do not count toward `maxExecutions`.
|
|
114
|
+
- Log nothing (or `null`) to skip — skipped ticks do not count toward `lifecycle.maxExecutions`.
|
|
110
115
|
- Prompt interpolation matches webhooks (`{{payload.path}}`).
|
|
111
116
|
|
|
117
|
+
## Attachment patterns
|
|
118
|
+
|
|
119
|
+
- **Attach to an agent** instead of a workflow: `.attach({ agent, prompt })` — the source's payload drives the agent prompt (interpolated like webhooks).
|
|
120
|
+
- **Fan-out to multiple targets**: chain `.attach(...).attach(...)` (or export an array of attachments) to bind one source to several workflows/agents.
|
|
121
|
+
- **Shared source definitions** can live under `src/triggers/sources/` — that subfolder is excluded from attachment discovery, so it's a safe place for source defs you import elsewhere.
|
|
122
|
+
- Sources also accept optional `name` / `description` metadata.
|
|
123
|
+
|
|
112
124
|
## Develop & audit
|
|
113
125
|
|
|
114
126
|
While building, invoke the workflow directly:
|
|
@@ -120,11 +132,12 @@ keystroke workflow run signup-pipeline --input '{"name":"Ada","email":"a@acme.co
|
|
|
120
132
|
Inspect trigger-driven runs:
|
|
121
133
|
|
|
122
134
|
```bash
|
|
123
|
-
keystroke trigger runs list signup:signup-pipeline
|
|
135
|
+
keystroke trigger runs list signup:signup-pipeline # --limit / --cursor / --trigger-type
|
|
124
136
|
keystroke trigger runs get signup:signup-pipeline <run-id> --include workflows,trace
|
|
125
|
-
keystroke trigger poll <poll-attachment-id> # on-demand poll
|
|
137
|
+
keystroke trigger poll <poll-attachment-id> # on-demand poll (--group to run a poll group)
|
|
138
|
+
keystroke trigger attachment disable <trigger-slug> <attachment-id> # pause; `enable` to resume
|
|
126
139
|
```
|
|
127
140
|
|
|
128
141
|
Check `src/triggers/` in your project for existing patterns before adding new ones.
|
|
129
142
|
|
|
130
|
-
Related: [workflows](.agents/skills/keystroke-workflows/SKILL.md), [
|
|
143
|
+
Related: [workflows](.agents/skills/keystroke-workflows/SKILL.md), [channels](.agents/skills/keystroke-channels/SKILL.md).
|
|
@@ -12,8 +12,8 @@ Workflows are **deterministic orchestration**: Zod input/output, a `run` functio
|
|
|
12
12
|
## Example: action chain
|
|
13
13
|
|
|
14
14
|
```ts
|
|
15
|
-
import { defineWorkflow } from "@keystrokehq/workflow";
|
|
16
|
-
import {
|
|
15
|
+
import { defineWorkflow } from "@keystrokehq/keystroke/workflow";
|
|
16
|
+
import { slackSendMessage } from "@keystrokehq/slack/actions";
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { researchSignup } from "../actions/research-signup";
|
|
19
19
|
import { signupBriefMessage } from "../lib/signup";
|
|
@@ -24,12 +24,16 @@ export default defineWorkflow({
|
|
|
24
24
|
output: z.object({ brief: z.string(), channel: z.string(), ts: z.string() }),
|
|
25
25
|
async run(input) {
|
|
26
26
|
const { brief } = await researchSignup.run(input);
|
|
27
|
-
|
|
27
|
+
const sent = await slackSendMessage.run({
|
|
28
|
+
channel: "#pipeline",
|
|
29
|
+
markdown_text: signupBriefMessage({ ...input, brief }),
|
|
30
|
+
});
|
|
31
|
+
return { brief, ...sent }; // sent provides { channel, ts }; merge in brief for the output schema
|
|
28
32
|
},
|
|
29
33
|
});
|
|
30
34
|
```
|
|
31
35
|
|
|
32
|
-
`research-signup` calls an agent; `
|
|
36
|
+
`research-signup` calls an agent; `slackSendMessage` is an integration action used **directly as a step** here — never wrapped in a custom action. Note the `run` return is spread to satisfy the `output` schema (the Slack action only returns `channel`/`ts`). Keep orchestration in the workflow; an action is a single leaf step and never calls another action.
|
|
33
37
|
|
|
34
38
|
## Run & audit
|
|
35
39
|
|
|
@@ -41,18 +45,29 @@ keystroke workflow runs get signup-pipeline <run-id> --include steps,trace
|
|
|
41
45
|
|
|
42
46
|
## How workflows get invoked
|
|
43
47
|
|
|
44
|
-
| From
|
|
45
|
-
|
|
|
46
|
-
| CLI
|
|
47
|
-
|
|
|
48
|
-
|
|
|
49
|
-
|
|
|
48
|
+
| From | How |
|
|
49
|
+
| ---------- | --------------------------------------------------------------------- |
|
|
50
|
+
| CLI | `keystroke workflow run {slug} --input '{...}'` |
|
|
51
|
+
| HTTP | `POST /workflows/{slug}` |
|
|
52
|
+
| Trigger | cron / webhook / poll attachment in `src/triggers/` |
|
|
53
|
+
| Agent tool | `defineWorkflowTool(workflow)` from `@keystrokehq/runtime` on an agent |
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
There is no first-class "call another workflow" step. To share logic between workflows, extract it into `src/lib/` (or an action, which adds typed IO and its own durable checkpoint). To run a workflow as its own tracked run, expose it as an agent tool (`defineWorkflowTool`) or invoke it over HTTP. Calling another workflow's `.run()` directly skips that workflow's input/output validation (the durable step context is preserved inside a run, but the IO schemas are not re-applied).
|
|
56
|
+
|
|
57
|
+
The workflow `slug` is its identifier and route key, and must be unique across agents, workflows, triggers, and actions.
|
|
58
|
+
|
|
59
|
+
## Durability (the model you must design for)
|
|
60
|
+
|
|
61
|
+
Workflows are **durable**: each `await` of an action, agent `.prompt()`, or `promptLlm` is recorded as a `step_completed` event. If a later step fails, the run **replays** the log — completed steps return their recorded result instead of running again — and resumes at the first unfinished step. Two rules follow:
|
|
62
|
+
|
|
63
|
+
- **Side effects go inside steps.** Code in the `run` body that isn't a step (network calls, writes, `Date.now()`, random) re-executes on every replay. Wrap it in an action/agent/`promptLlm` call so it's recorded once.
|
|
64
|
+
- **Keep control flow deterministic, steps idempotent.** Branch on input and recorded step results, not on values that change between attempts. A step can be retried after a transient failure, so design actions to tolerate being called twice with the same input. Retries are automatic — you don't write retry loops; the runtime emits `step_retrying` / `step_failed`.
|
|
65
|
+
|
|
66
|
+
Step ids are correlation ids: `step:<slug>#<occurrence>` (`#0`, `#1`, …), or `step:<id>` when pinned with `.stepId()`. If an action's position can shift, pin a stable `.stepId()` so replays line up. Durable waits (`ctx.sleep`, `ctx.hook`) suspend the run without holding a process open. See [authoring.md](references/authoring.md).
|
|
52
67
|
|
|
53
68
|
## Testing
|
|
54
69
|
|
|
55
|
-
Unit-test through `executeWorkflow` from `@keystrokehq/workflow` — never call `workflow.run(...)` directly (skips validation + action context). Stub costly actions by seeding `step_completed` events in a `MemoryEventLog`. See [testing.md](references/testing.md).
|
|
70
|
+
Unit-test through `executeWorkflow` from `@keystrokehq/keystroke/workflow` — never call `workflow.run(...)` directly (skips validation + action context). Stub costly actions by seeding `step_completed` events in a `MemoryEventLog`. See [testing.md](references/testing.md).
|
|
56
71
|
|
|
57
72
|
## Next references
|
|
58
73
|
|
|
@@ -10,7 +10,11 @@ async run(input) {
|
|
|
10
10
|
const summary = await support.prompt({
|
|
11
11
|
message: `Summarize signup research:\n${brief}`,
|
|
12
12
|
});
|
|
13
|
-
|
|
13
|
+
const sent = await slackSendMessage.run({
|
|
14
|
+
channel: "#pipeline",
|
|
15
|
+
markdown_text: signupBriefMessage({ ...input, brief }),
|
|
16
|
+
});
|
|
17
|
+
return { brief, ...sent };
|
|
14
18
|
}
|
|
15
19
|
```
|
|
16
20
|
|
|
@@ -18,7 +22,7 @@ Actions are leaf units: an action never calls another action — compose them in
|
|
|
18
22
|
|
|
19
23
|
## Agents as workflow steps
|
|
20
24
|
|
|
21
|
-
Import the agent and call `.prompt()` directly in the workflow `run`. Each call is a durable step keyed as `step:<
|
|
25
|
+
Import the agent and call `.prompt()` directly in the workflow `run`. Each call is a durable step keyed as `step:<agent-slug>#<occurrence>` (same scheme as actions). No wrapper action required when the step is just "prompt this agent."
|
|
22
26
|
|
|
23
27
|
```ts
|
|
24
28
|
const result = await support.prompt({
|
|
@@ -28,6 +32,110 @@ const result = await support.prompt({
|
|
|
28
32
|
|
|
29
33
|
Use a wrapper action only when the agent call should also be an agent tool, or when the step bundles non-prompt logic.
|
|
30
34
|
|
|
35
|
+
For an explicit step id, actions chain `.stepId("x")`; agent prompts take it as an option: `agent.prompt(input, { stepId: "x" })`.
|
|
36
|
+
|
|
37
|
+
## LLM steps (`promptLlm`)
|
|
38
|
+
|
|
39
|
+
For a one-shot LLM call without a full agent, use `promptLlm` (from `@keystrokehq/keystroke/workflow`). It's a first-class durable step keyed `step:promptLlm#<occurrence>`. The signature is `promptLlm(prompt, opts)` — the prompt string comes **first**, options second:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { promptLlm } from "@keystrokehq/keystroke/workflow";
|
|
43
|
+
|
|
44
|
+
// Returns a string when no outputSchema is given:
|
|
45
|
+
const summary = await promptLlm(`Summarize:\n${brief}`, {
|
|
46
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Pass `outputSchema` (Zod) to get a parsed, schema-validated object back instead of a string — the return type is inferred from the schema:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { z } from "zod";
|
|
54
|
+
|
|
55
|
+
const Category = z.object({ category: z.enum(["bug", "feature", "question"]) });
|
|
56
|
+
|
|
57
|
+
const { category } = await promptLlm(`Classify this ticket:\n${ticket}`, {
|
|
58
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
59
|
+
outputSchema: Category,
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Other options: `system`, `thinkingLevel`, `temperature`, `maxTokens`, `stepId`.
|
|
64
|
+
|
|
65
|
+
## Durability & retries
|
|
66
|
+
|
|
67
|
+
As each step completes, its result is written to a run event log. If a later step fails and the run is retried, Keystroke replays the log: completed steps return their recorded result instead of running again, and execution resumes at the first unfinished step. Design for this:
|
|
68
|
+
|
|
69
|
+
- **Put side effects inside steps.** Work in the `run` body that isn't an action / agent / `promptLlm` call (a raw `fetch`, a DB write, `Date.now()`, `Math.random()`) runs again on every replay. Move it into a step so it's recorded once.
|
|
70
|
+
- **Steps should be idempotent.** A step can be retried after a transient failure, so an action should tolerate being called twice with the same input (use idempotency keys for external writes where it matters).
|
|
71
|
+
- **Keep control flow deterministic.** Branch on the run's input and recorded step results — not on wall-clock time or randomness — so replays take the same path. Each step is recorded under `step:<slug>#<occurrence>`; pin `.stepId("x")` when an action's order can shift so replays still line up. Ids must be unique within a run.
|
|
72
|
+
|
|
73
|
+
You don't manage retries yourself. The runtime records `step_completed`, `step_retrying`, and `step_failed` events and surfaces them in `keystroke workflow runs get <slug> <run-id> --include steps,trace`.
|
|
74
|
+
|
|
75
|
+
## Parallel steps
|
|
76
|
+
|
|
77
|
+
Independent steps run concurrently with `Promise.all` — each `.run()` / `.prompt()` / `promptLlm` inside it is still its own durable step, recorded and replayed individually:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const [enriched, scored] = await Promise.all([
|
|
81
|
+
enrichLead.run({ leadId }),
|
|
82
|
+
scoreLead.run({ leadId }),
|
|
83
|
+
]);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Correlation ids are assigned per slug in array order (`step:enrich-lead#0`, `step:score-lead#0`). If the branches can change order between attempts (conditionally included, dynamic arrays), pin a stable `.stepId()` on each so replays line up:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const results = await Promise.all(
|
|
90
|
+
leads.map((lead) => enrichLead.run({ leadId: lead.id }).stepId(`enrich:${lead.id}`)),
|
|
91
|
+
);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
There's no built-in concurrency limit or fan-out helper — `Promise.all` runs everything at once. For bounded concurrency, batch the array yourself (e.g. chunk and `await` each chunk).
|
|
95
|
+
|
|
96
|
+
## Handling failures
|
|
97
|
+
|
|
98
|
+
Retries are **queue-level**: a failed run is re-enqueued (default 3 attempts, exponential backoff) and replays from the event log, so completed steps don't re-run. Each failed attempt emits `step_retrying`; the final failure emits `step_failed`. You don't write retry loops — make steps idempotent so a retried step is safe.
|
|
99
|
+
|
|
100
|
+
There's no built-in saga, compensation engine, or dead-letter queue. When a later step fails and you need to undo earlier work, orchestrate it yourself with `try/catch` in `run` — catching the error lets the run complete normally (so attach a `compensated` flag to the output rather than rethrowing if you've handled it):
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
async run(input) {
|
|
104
|
+
await reserveBooking.run({ id: input.bookingId });
|
|
105
|
+
try {
|
|
106
|
+
await chargeCustomer.run({ id: input.bookingId });
|
|
107
|
+
return { ok: true };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
await releaseBooking.run({ id: input.bookingId }); // best-effort cleanup
|
|
110
|
+
return { ok: false, compensated: true };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
If you let the error propagate instead, the run fails and the queue retries it — fine for transient failures, but it replays from the log, so any cleanup must itself be a recorded, idempotent step.
|
|
116
|
+
|
|
117
|
+
## Durable run context (`ctx`)
|
|
118
|
+
|
|
119
|
+
`run(input, ctx)` receives a second argument exposing `runId`, `trigger` (`api` | `cron` | `webhook` | `poll` | `retry`), and the durable wait primitives `ctx.sleep(...)` and `ctx.hook(...)`. Both **suspend** the run (the replay engine resumes it later) so long waits and external callbacks don't hold a process open.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// Durable delay: number (ms), duration string, or a Date
|
|
123
|
+
await ctx.sleep("1h");
|
|
124
|
+
|
|
125
|
+
// Durable hook: suspend until an external system POSTs to the resume URL
|
|
126
|
+
async run(input, ctx) {
|
|
127
|
+
const approval = ctx.hook<{ approved: boolean }>();
|
|
128
|
+
await slackSendMessage.run({
|
|
129
|
+
channel: "#approvals",
|
|
130
|
+
markdown_text: `Approve deploy? ${approval.resumeUrl}`,
|
|
131
|
+
});
|
|
132
|
+
const { approved } = await approval; // suspends here until resumed
|
|
133
|
+
return { approved };
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`ctx.hook(options?)` takes an optional `token` (reuse a stable resume token) and `schema` (Zod validation of the resume payload). The returned handle exposes `token` and `resumeUrl` before you await it — surface that URL so the approver/callback can resume the run.
|
|
138
|
+
|
|
31
139
|
## Agents inside actions
|
|
32
140
|
|
|
33
141
|
When an action must call an agent (e.g. the action is reused as an agent tool), call `.prompt()` inside the action's `run`. That prompt is not a separate workflow step — the action is.
|
|
@@ -36,6 +144,10 @@ When an action must call an agent (e.g. the action is reused as an agent tool),
|
|
|
36
144
|
|
|
37
145
|
Sync the app into `src/apps/`, author with `app.action()`, then `keystroke connect <slug>` before testing. Catalog integration actions from npm packages work once the app is connected.
|
|
38
146
|
|
|
39
|
-
##
|
|
147
|
+
## Slugs
|
|
148
|
+
|
|
149
|
+
Workflow `slug`, action `slug`, agent `slug`, and trigger `slug` share one namespace — pick unique names (`signup-pipeline`, not `pipeline`). The workflow's `slug` is also its HTTP route segment (`POST /workflows/{slug}`).
|
|
150
|
+
|
|
151
|
+
## Subscription mode
|
|
40
152
|
|
|
41
|
-
|
|
153
|
+
`defineWorkflow` accepts an optional `subscription: { mode: "system" | "subscribable" }` to control how the workflow can be subscribed to. Omit it for the default behavior.
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unit tests (in-process, no server)
|
|
4
4
|
|
|
5
|
-
Test through `executeWorkflow` from `@keystrokehq/workflow` — it parses input/output and wires up the action runner. **Never call `workflow.run(...)` directly**:
|
|
5
|
+
Test through `executeWorkflow` from `@keystrokehq/keystroke/workflow` — it parses input/output and wires up the action runner. **Never call `workflow.run(...)` directly in a test**: called at top level (outside `executeWorkflow`) it skips Zod validation and has no action context, so any action step throws.
|
|
6
6
|
|
|
7
|
-
`executeWorkflow` runs the workflow via the durable replay engine and resolves to a `ReplayResult`: `{ status: "completed", output }`, `{ status: "failed", error }`,
|
|
7
|
+
`executeWorkflow` runs the workflow via the durable replay engine and resolves to a `ReplayResult`: `{ status: "completed", output }`, `{ status: "failed", error }`, `{ status: "suspended", items }` (when the body hits `ctx.sleep`/`ctx.hook`), or `{ status: "canceled" }`. Assert on `status` and read `output`.
|
|
8
8
|
|
|
9
9
|
`keystroke init` scaffolds Vitest and a starter test (e.g. `src/workflows/greeting.test.ts`):
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
12
|
import { describe, expect, it } from "vitest";
|
|
13
|
-
import { executeWorkflow } from "@keystrokehq/workflow";
|
|
13
|
+
import { executeWorkflow } from "@keystrokehq/keystroke/workflow";
|
|
14
14
|
import greeting from "./greeting";
|
|
15
15
|
|
|
16
16
|
describe("greeting workflow", () => {
|
|
@@ -27,7 +27,7 @@ For multi-step workflows:
|
|
|
27
27
|
|
|
28
28
|
```ts
|
|
29
29
|
import { describe, expect, it } from "vitest";
|
|
30
|
-
import { executeWorkflow } from "@keystrokehq/workflow";
|
|
30
|
+
import { executeWorkflow } from "@keystrokehq/keystroke/workflow";
|
|
31
31
|
import workflow from "../signup-pipeline";
|
|
32
32
|
|
|
33
33
|
it("runs the pipeline", async () => {
|
|
@@ -39,7 +39,7 @@ it("runs the pipeline", async () => {
|
|
|
39
39
|
|
|
40
40
|
expect(result.status).toBe("completed");
|
|
41
41
|
if (result.status === "completed") {
|
|
42
|
-
expect(result.output).toMatchObject({ channel: "#
|
|
42
|
+
expect(result.output).toMatchObject({ channel: "#pipeline" });
|
|
43
43
|
}
|
|
44
44
|
});
|
|
45
45
|
```
|
|
@@ -51,7 +51,7 @@ Deterministic actions (pure logic, no network/LLM) need no mocks — run the wor
|
|
|
51
51
|
Steps are checkpointed in the durable event log as `step_completed` events keyed by a stable **correlation id**. Before running a step the action runner checks the log for that correlation id; pre-seed a `step_completed` event to skip real work (LLM/agent/HTTP calls) and feed a fixture:
|
|
52
52
|
|
|
53
53
|
```ts
|
|
54
|
-
import { executeWorkflow, MemoryEventLog } from "@keystrokehq/workflow";
|
|
54
|
+
import { executeWorkflow, MemoryEventLog } from "@keystrokehq/keystroke/workflow";
|
|
55
55
|
import workflow from "../signup-pipeline";
|
|
56
56
|
|
|
57
57
|
it("drafts without calling the agent", async () => {
|
|
@@ -83,7 +83,7 @@ it("drafts without calling the agent", async () => {
|
|
|
83
83
|
Rules:
|
|
84
84
|
|
|
85
85
|
- Pass a fixed `runId` and the same `MemoryEventLog` to `executeWorkflow`.
|
|
86
|
-
- The correlation id is `step:<
|
|
86
|
+
- The correlation id is `step:<slug>#<occurrence>` — `#0` for the first call to that action/agent, `#1` for the second, and so on — unless an explicit step id was set (`action.stepId("x")` or `agent.prompt(input, { stepId: "x" })`), in which case it is `step:x`.
|
|
87
87
|
- The action **output** is stored as the event `data` directly (no wrapper). It is re-validated against the action's output schema, so it must be schema-valid.
|
|
88
88
|
|
|
89
89
|
This is the preferred mock: schema-checked, no module-mock hoisting, and it still exercises the real `run` orchestration (branching, loops, output shape) — only the action bodies are stubbed.
|
|
@@ -92,11 +92,19 @@ This is the preferred mock: schema-checked, no module-mock hoisting, and it stil
|
|
|
92
92
|
|
|
93
93
|
When you'd rather stub the underlying dependency (e.g. the agent inside an action) or assert how it was called, use `vi.mock`:
|
|
94
94
|
|
|
95
|
+
`agent.prompt()` resolves to a `PromptResponse` — `{ sessionId, messages, error, canceled?, output? }` (`output` is the parsed result when the prompt was called with an `outputSchema`), **not** a bare messages array. Mock that shape:
|
|
96
|
+
|
|
95
97
|
```ts
|
|
96
98
|
import { vi } from "vitest";
|
|
97
99
|
|
|
98
100
|
vi.mock("../agents/support", () => ({
|
|
99
|
-
default: {
|
|
101
|
+
default: {
|
|
102
|
+
prompt: vi.fn().mockResolvedValue({
|
|
103
|
+
sessionId: "test-session",
|
|
104
|
+
messages: [{ role: "assistant", content: "mocked" }],
|
|
105
|
+
error: null,
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
100
108
|
}));
|
|
101
109
|
```
|
|
102
110
|
|
|
@@ -108,7 +116,7 @@ Validation happens at the `executeWorkflow` boundary, so contract tests are free
|
|
|
108
116
|
await expect(executeWorkflow(workflow, { email: "x" } as never)).rejects.toThrow();
|
|
109
117
|
```
|
|
110
118
|
|
|
111
|
-
## Debug runs
|
|
119
|
+
## Debug runs from the CLI
|
|
112
120
|
|
|
113
121
|
```bash
|
|
114
122
|
keystroke workflow run signup-pipeline --input '{"name":"Ada","email":"ada@acme.com","company":"Acme"}'
|
|
@@ -4,26 +4,35 @@ Keystroke project — agents, workflows, actions, and triggers under `src/`. See
|
|
|
4
4
|
|
|
5
5
|
## Getting started
|
|
6
6
|
|
|
7
|
+
Keystroke is deploy-first: build in `src/`, deploy to your platform project, then run and inspect what's deployed with the CLI.
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
|
-
pnpm install
|
|
9
|
-
|
|
10
|
-
keystroke
|
|
10
|
+
pnpm install # @keystrokehq/* from GitHub Packages (see .npmrc)
|
|
11
|
+
keystroke auth login # once
|
|
12
|
+
keystroke deploy --project <id> # build + ship src/ (see: keystroke project list)
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Prompt the hello agent:
|
|
15
|
+
<!-- example:start -->
|
|
16
|
+
Prompt the hello agent and run the greeting workflow against your deployed project:
|
|
16
17
|
|
|
17
18
|
```bash
|
|
18
19
|
keystroke agent prompt hello --message "Hi"
|
|
20
|
+
keystroke workflow run greeting --input '{"name":"Ada"}'
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
Use `--filter agents/hello` (or `workflows/greeting`) to redeploy a single module fast.
|
|
24
|
+
<!-- example:end -->
|
|
25
|
+
|
|
26
|
+
## Run locally (optional)
|
|
27
|
+
|
|
28
|
+
To iterate offline without deploying, run a local server. Set `ANTHROPIC_API_KEY` and any integration keys in `.env` (created from `.env.example` on init), then:
|
|
22
29
|
|
|
23
30
|
```bash
|
|
24
|
-
keystroke
|
|
31
|
+
keystroke dev # watch src/, rebuild, restart the API (API :3002, dashboard :3000)
|
|
25
32
|
```
|
|
26
33
|
|
|
34
|
+
The same `keystroke agent` / `keystroke workflow` commands then target your local server. Login at `http://localhost:3000` (`admin@example.com` / `adminadmin` by default).
|
|
35
|
+
|
|
27
36
|
## Linting
|
|
28
37
|
|
|
29
38
|
```bash
|
|
@@ -39,7 +48,9 @@ pnpm test:unit # src/**/*.test.ts
|
|
|
39
48
|
pnpm test:int # src/**/*.int.test.ts (needs ANTHROPIC_API_KEY for agent tests)
|
|
40
49
|
```
|
|
41
50
|
|
|
51
|
+
<!-- example:start -->
|
|
42
52
|
Example: `src/workflows/greeting.test.ts` uses `executeWorkflow` to test the `greeting` workflow in-process.
|
|
53
|
+
<!-- example:end -->
|
|
43
54
|
|
|
44
55
|
## Layout
|
|
45
56
|
|
|
@@ -6,6 +6,6 @@ describe("greeting workflow", () => {
|
|
|
6
6
|
it("returns a greeting for a name", async () => {
|
|
7
7
|
const result = await executeWorkflow(greeting, { name: "Ada" });
|
|
8
8
|
|
|
9
|
-
expect(result).toEqual({ greeting: "Hello, Ada!" });
|
|
9
|
+
expect(result).toEqual({ status: "completed", output: { greeting: "Hello, Ada!" } });
|
|
10
10
|
});
|
|
11
11
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keystrokehq/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/keystrokehq/keystroke.git",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"tsx": "^4.22.3",
|
|
41
41
|
"typescript": "^6.0.3",
|
|
42
42
|
"vitest": "^4.1.7",
|
|
43
|
-
"@keystrokehq/oxlint-config": "0.0.4",
|
|
44
43
|
"@keystrokehq/tsconfig": "0.0.3",
|
|
45
44
|
"@keystrokehq/tsdown-config": "0.0.3",
|
|
45
|
+
"@keystrokehq/oxlint-config": "0.0.4",
|
|
46
46
|
"@keystrokehq/vitest-config": "0.0.5"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# API target routing
|
|
2
|
-
|
|
3
|
-
The CLI resolves a `baseUrl` before runtime commands (`workflow`, `agent`, `trigger`, `app`, `connect`, `health`). Implementation: `apps/cli/src/resolve-api-target.ts`.
|
|
4
|
-
|
|
5
|
-
## Mental model
|
|
6
|
-
|
|
7
|
-
- **Local** = your **keystroke server** (`keystroke dev` / `keystroke start`). Use for authoring, unit/integration tests in the repo, and manual runs while iterating.
|
|
8
|
-
- **Cloud** = **keystroke platform** control plane routes to a deployed keystroke server (`/api/projects/:projectId/*`). Use for live invocation, listing triggers on deploy, webhook URLs, and auditing production runs.
|
|
9
|
-
|
|
10
|
-
Auth is independent of target. `keystroke auth login` stores a bearer token in the OS keychain keyed by `webUrl` hostname and persists matching `webUrl` / `platformUrl` in config. Cloud API calls attach that token; local dev often runs without auth unless `BETTER_AUTH_SECRET` is set.
|
|
11
|
-
|
|
12
|
-
## Config (`~/.keystroke`)
|
|
13
|
-
|
|
14
|
-
| Key | Default | Role |
|
|
15
|
-
| ----------------- | -------------------------- | -------------------------------- |
|
|
16
|
-
| `platformUrl` | `https://api.keystroke.ai` | Keystroke platform control plane |
|
|
17
|
-
| `webUrl` | `https://keystroke.ai` | Dashboard + device login |
|
|
18
|
-
| `activeProjectId` | unset | Deployed project id (persistent) |
|
|
19
|
-
| `apiTarget` | inferred | `local` or `platform` |
|
|
20
|
-
|
|
21
|
-
Local commands use `PUBLIC_PLATFORM_URL` from the project `.env` (dev default `http://localhost:3002` when unset). They do not read `platformUrl` when `apiTarget=local`, so cloud login does not redirect local workflow runs to production.
|
|
22
|
-
|
|
23
|
-
If `apiTarget` is unset and `activeProjectId` exists, effective target is **platform** (backward compatible). Explicit `apiTarget=local` overrides that without clearing `activeProjectId`.
|
|
24
|
-
|
|
25
|
-
`keystroke config show` prints effective `apiTarget` and any active dev session.
|
|
26
|
-
|
|
27
|
-
## Switching without losing cloud state
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
keystroke deploy --project proj_abc # activeProjectId=proj_abc, apiTarget=platform
|
|
31
|
-
keystroke config use local # apiTarget=local; proj_abc unchanged
|
|
32
|
-
keystroke workflow run foo --input '{}' # → localhost:3002
|
|
33
|
-
keystroke config use cloud # apiTarget=platform; same proj_abc
|
|
34
|
-
keystroke trigger list # → platform runtime
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Swap cloud project:
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
keystroke config use project proj_xyz
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
Target another project once:
|
|
44
|
-
|
|
45
|
-
```bash
|
|
46
|
-
keystroke --project proj_xyz workflow runs list my-workflow
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
## Dev session auto-local
|
|
50
|
-
|
|
51
|
-
`keystroke dev` writes `dev-session.json` beside config (pid, port, serverUrl). While that process is alive, resolved target is **local** even if `apiTarget=platform`. Stopping dev removes the session; routing falls back to config.
|
|
52
|
-
|
|
53
|
-
`--project` still wins over the dev session when you need to hit cloud while dev is up.
|
|
54
|
-
|
|
55
|
-
## Global flags
|
|
56
|
-
|
|
57
|
-
| Flag | Effect |
|
|
58
|
-
| ---------------- | -------------------------------------------------------------------------- |
|
|
59
|
-
| `--local` | Force local API (`PUBLIC_PLATFORM_URL` / project `.env`) for this command |
|
|
60
|
-
| `--project <id>` | Force platform runtime for that project; does not update `activeProjectId` |
|
|
61
|
-
|
|
62
|
-
## Platform projects
|
|
63
|
-
|
|
64
|
-
Platform projects are deploy targets on the control plane. List or create them before the first deploy:
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
keystroke project list
|
|
68
|
-
keystroke project create --name "My app"
|
|
69
|
-
keystroke deploy --project <id> # activates runtime; sets activeProjectId
|
|
70
|
-
keystroke config use project <id> # swap default cloud target
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
New projects start **inactive**. The CLI hints with `keystroke deploy --project <id>` when appropriate.
|
|
74
|
-
|
|
75
|
-
## When agents should choose local
|
|
76
|
-
|
|
77
|
-
- User is editing `src/` and wants to test a workflow or agent change
|
|
78
|
-
- `keystroke dev` or `keystroke start` is running (or should be started first)
|
|
79
|
-
- Debugging with fast iteration; no deploy mentioned
|
|
80
|
-
|
|
81
|
-
## When agents should choose cloud
|
|
82
|
-
|
|
83
|
-
- User asks about deployed triggers, webhook URLs, or production runs
|
|
84
|
-
- After `keystroke deploy` or when operating an existing platform project
|
|
85
|
-
- Listing or invoking resources on the live environment
|
|
86
|
-
|
|
87
|
-
If unsure, run `keystroke config show` and prefer **local** when the task is development/testing.
|