@fkws/klonk 0.0.22 → 0.0.24
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/README.md +106 -22
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +14 -13
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,15 +20,14 @@
|
|
|
20
20
|
|
|
21
21
|
## Introduction
|
|
22
22
|
|
|
23
|
-
Klonk is a code-first, type-safe automation engine
|
|
23
|
+
Klonk is a code-first, type-safe automation engine. It provides composable primitives to build workflows and state machines with autocomplete and type inference. If you've ever wanted to build event-driven automations or a stateful agent in code, with all the benefits of TypeScript, Klonk is for you.
|
|
24
24
|
|
|
25
|
-

|
|
26
25
|

|
|
27
26
|
|
|
28
27
|
The two main features are **Workflows** and **Machines**.
|
|
29
28
|
|
|
30
|
-
- **Workflows**: Combine triggers with a series of tasks (a `Playlist`) to automate processes.
|
|
31
|
-
- **Machines**:
|
|
29
|
+
- **Workflows**: Combine triggers with a series of tasks (a `Playlist`) to automate processes. Example: "when a file is added to Dropbox, parse it, and create an entry in Notion."
|
|
30
|
+
- **Machines**: Finite state machines where each state has its own `Playlist` of tasks and conditional transitions to other states. Useful for agents, multi-step processes, or systems with stateful logic.
|
|
32
31
|
|
|
33
32
|
## Installation
|
|
34
33
|
|
|
@@ -38,9 +37,98 @@ bun add @fkws/klonk
|
|
|
38
37
|
npm i @fkws/klonk
|
|
39
38
|
```
|
|
40
39
|
|
|
40
|
+
### Compatibility
|
|
41
|
+
|
|
42
|
+
| Requirement | Support |
|
|
43
|
+
|-------------|---------|
|
|
44
|
+
| **Runtimes** | Node.js 18+, Bun 1.0+, Deno (via npm specifier, best-effort) |
|
|
45
|
+
| **Module** | ESM (native) and CJS (via bundled `/dist`) |
|
|
46
|
+
| **TypeScript** | 5.0+ (required for full type inference) |
|
|
47
|
+
| **Dependencies** | Zero runtime dependencies |
|
|
48
|
+
|
|
49
|
+
**Status:** Pre-1.0, API may change between minor versions. Aiming for stability by 1.0.
|
|
50
|
+
|
|
51
|
+
## Quickstart
|
|
52
|
+
|
|
53
|
+
Copy-paste this to see Klonk in action. One trigger, two tasks, fully typed outputs:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { Task, Trigger, Workflow, Railroad, isOk } from "@fkws/klonk";
|
|
57
|
+
|
|
58
|
+
// 1. Define two simple tasks
|
|
59
|
+
class FetchUser<I extends string> extends Task<{ userId: string }, { name: string; email: string }, I> {
|
|
60
|
+
async validateInput(input: { userId: string }) { return !!input.userId; }
|
|
61
|
+
async run(input: { userId: string }): Promise<Railroad<{ name: string; email: string }>> {
|
|
62
|
+
if (input.userId !== "123") return { success: false, error: new Error("User not found") };
|
|
63
|
+
return { success: true, data: { name: "Alice", email: "alice@example.com" } };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class SendEmail<I extends string> extends Task<{ to: string; subject: string }, { sent: boolean }, I> {
|
|
68
|
+
async validateInput(input: { to: string; subject: string }) { return !!input.to; }
|
|
69
|
+
async run(input: { to: string; subject: string }): Promise<Railroad<{ sent: boolean }>> {
|
|
70
|
+
console.log(`📧 Sending "${input.subject}" to ${input.to}`);
|
|
71
|
+
return { success: true, data: { sent: true } };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Create a trigger (fires once with a userId)
|
|
76
|
+
class ManualTrigger<I extends string> extends Trigger<I, { userId: string }> {
|
|
77
|
+
async start() { this.pushEvent({ userId: "123" }); }
|
|
78
|
+
async stop() {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Wire it up: trigger → playlist with typed outputs
|
|
82
|
+
const workflow = Workflow.create()
|
|
83
|
+
.addTrigger(new ManualTrigger("manual"))
|
|
84
|
+
.setPlaylist(p => p
|
|
85
|
+
.addTask(new FetchUser("fetch-user"))
|
|
86
|
+
.input((source) => ({ userId: source.data.userId })) // ← source.data is typed!
|
|
87
|
+
|
|
88
|
+
.addTask(new SendEmail("send-email"))
|
|
89
|
+
.input((source, outputs) => {
|
|
90
|
+
// outputs["fetch-user"] is typed as Railroad<{ name, email }> | null
|
|
91
|
+
const user = outputs["fetch-user"];
|
|
92
|
+
if (!user || !isOk(user)) return null; // skip if failed
|
|
93
|
+
return { to: user.data.email, subject: `Welcome, ${user.data.name}!` };
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
workflow.start({ callback: (src, out) => console.log("✅ Done!", out) });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**What you just saw:**
|
|
101
|
+
- `source.data.userId` is typed from the trigger
|
|
102
|
+
- `outputs["fetch-user"]` is typed by the task's ident string literal
|
|
103
|
+
- `user.data.email` is narrowed after the `isOk()` check
|
|
104
|
+
|
|
105
|
+
## TypeScript Magic Moment
|
|
106
|
+
|
|
107
|
+
Klonk's type inference isn't marketing. Here's proof:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { Machine } from "@fkws/klonk";
|
|
111
|
+
|
|
112
|
+
// Declare states upfront → autocomplete for ALL transitions
|
|
113
|
+
const machine = Machine.create<{ count: number }>()
|
|
114
|
+
.withStates("idle", "processing", "done") // ← These drive autocomplete
|
|
115
|
+
.addState("idle", node => node
|
|
116
|
+
.setPlaylist(p => p/* ... */)
|
|
117
|
+
.addTransition({
|
|
118
|
+
to: "processing", // ← Type "pro" and your IDE suggests "processing"
|
|
119
|
+
condition: async () => true,
|
|
120
|
+
weight: 1
|
|
121
|
+
})
|
|
122
|
+
// @ts-expect-error - "typo-state" is not a valid state
|
|
123
|
+
.addTransition({ to: "typo-state", condition: async () => true, weight: 1 })
|
|
124
|
+
, { initial: true });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The `withStates<...>()` pattern means **you can't transition to a state that doesn't exist**. TypeScript catches it at compile time, not runtime.
|
|
128
|
+
|
|
41
129
|
## Core Concepts
|
|
42
130
|
|
|
43
|
-
|
|
131
|
+
Klonk has a few concepts that work together.
|
|
44
132
|
|
|
45
133
|
### Task
|
|
46
134
|
|
|
@@ -87,15 +175,11 @@ if (isErr(result)) {
|
|
|
87
175
|
|
|
88
176
|
#### Why Railroad?
|
|
89
177
|
|
|
90
|
-
The name "Railroad" comes from Railway Oriented Programming
|
|
91
|
-
|
|
92
|
-
- **No uncaught exceptions** - errors are values, not thrown
|
|
93
|
-
- **Explicit error handling** - the type system forces you to handle failures
|
|
94
|
-
- **Familiar patterns** - if you love Rust's `Result`, you'll feel right at home
|
|
178
|
+
The name "Railroad" comes from Railway Oriented Programming, where success travels the "happy path" and errors get shunted to the "error track". Combined with TypeScript's type narrowing, you get explicit error handling without exceptions. If you like Rust's `Result`, you'll feel at home.
|
|
95
179
|
|
|
96
180
|
### Playlist
|
|
97
181
|
|
|
98
|
-
A `Playlist` is a sequence of `Tasks` executed in order.
|
|
182
|
+
A `Playlist` is a sequence of `Tasks` executed in order. Each task has access to the outputs of all previous tasks, in a fully type-safe way. You build a `Playlist` by chaining `.addTask().input()` calls:
|
|
99
183
|
|
|
100
184
|
```typescript
|
|
101
185
|
import { isOk } from "@fkws/klonk";
|
|
@@ -161,7 +245,7 @@ Workflow.create()
|
|
|
161
245
|
node.preventRetry() // Task failures throw immediately
|
|
162
246
|
```
|
|
163
247
|
|
|
164
|
-
Default behavior: infinite retries at 1000ms delay.
|
|
248
|
+
Default behavior: infinite retries at 1000ms delay. This is designed for long-running daemons and background workers where resilience matters. **For request/response contexts** (APIs, CLIs, one-shot scripts), set `.retryLimit(n)` to cap attempts or use `.preventRetry()` to fail fast.
|
|
165
249
|
|
|
166
250
|
### Trigger
|
|
167
251
|
|
|
@@ -173,11 +257,11 @@ A `Workflow` connects one or more `Triggers` to a `Playlist`. When a trigger fir
|
|
|
173
257
|
|
|
174
258
|
### Machine
|
|
175
259
|
|
|
176
|
-
A `Machine` is a finite state machine. You build it by declaring all state identifiers upfront with `.withStates
|
|
260
|
+
A `Machine` is a finite state machine. You build it by declaring all state identifiers upfront with `.withStates(...)`, then adding states with `.addState()`:
|
|
177
261
|
|
|
178
262
|
```typescript
|
|
179
263
|
Machine.create<MyStateData>()
|
|
180
|
-
.withStates
|
|
264
|
+
.withStates("idle", "running", "complete") // Declare all states
|
|
181
265
|
.addState("idle", node => node
|
|
182
266
|
.setPlaylist(p => p.addTask(...).input(...))
|
|
183
267
|
.addTransition({ to: "running", condition: ..., weight: 1 }) // Autocomplete!
|
|
@@ -206,14 +290,14 @@ Notes:
|
|
|
206
290
|
|
|
207
291
|
## Features
|
|
208
292
|
|
|
209
|
-
- **Type-Safe & Autocompleted**: Klonk
|
|
210
|
-
- **Code-First**: Define your automations directly in TypeScript. No YAML, no drag-and-drop UIs.
|
|
211
|
-
- **Composable & Extensible**: The core primitives (`Task`, `Trigger`) are simple abstract classes,
|
|
293
|
+
- **Type-Safe & Autocompleted**: Klonk uses TypeScript's inference so the inputs and outputs of every step are strongly typed. You'll know at compile time if your logic is sound.
|
|
294
|
+
- **Code-First**: Define your automations directly in TypeScript. No YAML, no drag-and-drop UIs.
|
|
295
|
+
- **Composable & Extensible**: The core primitives (`Task`, `Trigger`) are simple abstract classes, so you can create your own reusable components.
|
|
212
296
|
- **Flexible Execution**: `Machines` run with configurable modes via `run(state, options)`: `any`, `leaf`, `roundtrip`, or `infinitely` (with optional `interval`).
|
|
213
297
|
|
|
214
298
|
## Klonkworks: Pre-built Components
|
|
215
299
|
|
|
216
|
-
Coming soon(ish)! Klonkworks will be a
|
|
300
|
+
Coming soon(ish)! Klonkworks will be a collection of pre-built Tasks, Triggers, and integrations that connect to various services, so you don't have to build everything from scratch.
|
|
217
301
|
|
|
218
302
|
## Code Examples
|
|
219
303
|
|
|
@@ -324,7 +408,7 @@ export class IntervalTrigger<TIdent extends string> extends Trigger<TIdent, { no
|
|
|
324
408
|
<details>
|
|
325
409
|
<summary><b>Building a Workflow</b></summary>
|
|
326
410
|
|
|
327
|
-
Workflows
|
|
411
|
+
Workflows work well for event-driven automations. This example triggers when a new invoice PDF is added to a Dropbox folder, parses the invoice, and creates a new item in a Notion database.
|
|
328
412
|
|
|
329
413
|
Notice the fluent `.addTask(task).input(builder)` syntax - each task's input builder has access to `source` (trigger data) and `outputs` (all previous task results), with full type inference!
|
|
330
414
|
|
|
@@ -450,7 +534,7 @@ workflow.start({
|
|
|
450
534
|
<details>
|
|
451
535
|
<summary><b>Building a Machine</b></summary>
|
|
452
536
|
|
|
453
|
-
`Machines`
|
|
537
|
+
`Machines` work well for stateful agents. This example shows an AI agent that takes a user's query, refines it, performs a web search, and generates a response.
|
|
454
538
|
|
|
455
539
|
The `Machine` manages a `StateData` object. Each `StateNode`'s `Playlist` can modify this state, and the `Transitions` between states use it to decide which state to move to next.
|
|
456
540
|
|
|
@@ -489,7 +573,7 @@ const client = new OpenRouterClient(process.env.OPENROUTER_API_KEY!)
|
|
|
489
573
|
const webSearchAgent = Machine
|
|
490
574
|
.create<StateData>()
|
|
491
575
|
// Declare all states upfront for transition autocomplete
|
|
492
|
-
.withStates
|
|
576
|
+
.withStates("refine_and_extract", "search_web", "generate_response")
|
|
493
577
|
.addState("refine_and_extract", node => node
|
|
494
578
|
.setPlaylist(p => p
|
|
495
579
|
// Refine the user's input
|
|
@@ -589,7 +673,7 @@ console.log(state.finalResponse);
|
|
|
589
673
|
|
|
590
674
|
## Type System
|
|
591
675
|
|
|
592
|
-
Klonk's type system is
|
|
676
|
+
Klonk's type system is minimal. Here's how it works:
|
|
593
677
|
|
|
594
678
|
### Core Types
|
|
595
679
|
|
package/dist/index.cjs
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -24,16 +24,16 @@ declare function isOk<
|
|
|
24
24
|
T,
|
|
25
25
|
E
|
|
26
26
|
>(r: Railroad<T, E>): r is {
|
|
27
|
-
success: true
|
|
28
|
-
data: T
|
|
27
|
+
readonly success: true
|
|
28
|
+
readonly data: T
|
|
29
29
|
};
|
|
30
30
|
/** Type guard: returns true if the Railroad is an error */
|
|
31
31
|
declare function isErr<
|
|
32
32
|
T,
|
|
33
33
|
E
|
|
34
34
|
>(r: Railroad<T, E>): r is {
|
|
35
|
-
success: false
|
|
36
|
-
error: E
|
|
35
|
+
readonly success: false
|
|
36
|
+
readonly error: E
|
|
37
37
|
};
|
|
38
38
|
/** Returns the data if success, throws the error if failure (like Rust's unwrap) */
|
|
39
39
|
declare function unwrap<
|
|
@@ -463,7 +463,7 @@ declare class StateNode<
|
|
|
463
463
|
next(data: TStateData): Promise<StateNode<TStateData> | null>;
|
|
464
464
|
}
|
|
465
465
|
/**
|
|
466
|
-
* Returned by `Machine.create()` - you must call `.withStates
|
|
466
|
+
* Returned by `Machine.create()` - you must call `.withStates(...)` to declare state idents.
|
|
467
467
|
*
|
|
468
468
|
* This ensures all state identifiers are known upfront for full transition autocomplete.
|
|
469
469
|
*/
|
|
@@ -472,17 +472,18 @@ interface MachineNeedsStates<TStateData> {
|
|
|
472
472
|
* Declare all state identifiers that will be used in this machine.
|
|
473
473
|
* This enables full autocomplete for transition targets.
|
|
474
474
|
*
|
|
475
|
-
* @
|
|
475
|
+
* @param states - All state idents as string arguments. If omitted, any string is allowed (useful for tests).
|
|
476
476
|
* @returns The machine, ready for adding states.
|
|
477
477
|
*
|
|
478
478
|
* @example
|
|
479
479
|
* Machine.create<MyState>()
|
|
480
|
-
* .withStates
|
|
480
|
+
* .withStates("idle", "running", "complete")
|
|
481
481
|
* .addState("idle", node => node
|
|
482
482
|
* .addTransition({ to: "running", ... }) // Autocomplete works!
|
|
483
483
|
* )
|
|
484
484
|
*/
|
|
485
|
-
withStates
|
|
485
|
+
withStates(): Machine<TStateData, string>;
|
|
486
|
+
withStates<const T extends readonly string[]>(...states: T): Machine<TStateData, T[number]>;
|
|
486
487
|
}
|
|
487
488
|
/**
|
|
488
489
|
* A finite state machine that coordinates execution of `StateNode` playlists
|
|
@@ -518,7 +519,7 @@ declare class Machine<
|
|
|
518
519
|
*/
|
|
519
520
|
private sleep;
|
|
520
521
|
/**
|
|
521
|
-
* Create a new Machine. You must call `.withStates
|
|
522
|
+
* Create a new Machine. You must call `.withStates(...)` next to declare
|
|
522
523
|
* all state identifiers before adding states.
|
|
523
524
|
*
|
|
524
525
|
* @template TStateData - The shape of the mutable state carried through the machine.
|
|
@@ -526,7 +527,7 @@ declare class Machine<
|
|
|
526
527
|
*
|
|
527
528
|
* @example
|
|
528
529
|
* Machine.create<MyState>()
|
|
529
|
-
* .withStates
|
|
530
|
+
* .withStates("idle", "running")
|
|
530
531
|
* .addState("idle", node => ...)
|
|
531
532
|
*/
|
|
532
533
|
static create<TStateData>(): MachineNeedsStates<TStateData>;
|
|
@@ -536,7 +537,7 @@ declare class Machine<
|
|
|
536
537
|
*
|
|
537
538
|
* @internal
|
|
538
539
|
*/
|
|
539
|
-
withStates<
|
|
540
|
+
withStates<const T extends readonly string[]>(..._states: T): Machine<TStateData, T[number]>;
|
|
540
541
|
/**
|
|
541
542
|
* Finalize the machine by resolving state transitions and locking configuration.
|
|
542
543
|
* Must be called before `start` or `run`.
|
|
@@ -572,9 +573,9 @@ declare class Machine<
|
|
|
572
573
|
* .addTransition({ to: "idle", condition: async () => true, weight: 1 })
|
|
573
574
|
* )
|
|
574
575
|
*/
|
|
575
|
-
addState<const TIdent extends
|
|
576
|
+
addState<const TIdent extends AllStateIdents>(ident: TIdent, builder: (node: StateNode<TStateData, TIdent, AllStateIdents>) => StateNode<TStateData, TIdent, AllStateIdents>, options?: {
|
|
576
577
|
initial?: boolean
|
|
577
|
-
}): Machine<TStateData, AllStateIdents
|
|
578
|
+
}): Machine<TStateData, AllStateIdents>;
|
|
578
579
|
/**
|
|
579
580
|
* Attach a logger to this machine. If the machine has an initial state set,
|
|
580
581
|
* the logger will be propagated to all currently reachable states.
|
package/dist/index.js
CHANGED
package/package.json
CHANGED