This file is the behavioral contract for AI agents working with the Effection codebase.
Agents must not invent APIs, must not infer semantics from other ecosystems, and must ground claims in the public API and repository code.
If you are unsure whether something exists, consult the API reference: https://frontside.com/effection/api/
- Operations are lazy. They execute only when interpreted (e.g.
yield*,run(),Scope.run(),spawn()). - Promises are eager. Creating a promise (or calling an
asyncfunction) starts work;awaitonly observes completion. - You must not claim that a promise is "inert until awaited".
- You must not use
awaitinside a generator function (function*). Useyield*with an operation instead (e.g.yield* until(promise)).
- Scope hierarchy is created automatically by the interpreter; application code should not manage scopes manually.
- "Lexical" in Effection: scope hierarchy follows the lexical structure of
operation invocation sites (e.g.
yield*,spawn,Scope.run), not where references are stored or later used. - Work is owned by Scopes.
- When a scope exits, all work created in that scope is halted.
- References do not extend lifetimes. Returning a
Task,Scope,Stream, orAbortSignaldoes not keep it alive.
- Values may escape scopes.
- Ongoing effects must not escape: tasks, resources, streams/subscriptions, and context mutations must remain scope-bound.
- An
Operation<T>is a recipe for work. It does nothing by itself. - Operations are typically created by invoking a generator function
(
function*).
- A
Future<T>is both:- an Effection operation (
yield* future) - a Promise (
await future)
- an Effection operation (
- A
Task<T>is aFuture<T>representing a concurrently running operation. - A task does not own lifetime or context; its scope does.
- You should prefer
main()when writing an entire program in Effection. - Inside
main(), preferyield* exit(status, message?)for termination; do not callprocess.exit()/Deno.exit()directly (it bypasses orderly shutdown).
exit()is an operation intended to be used from withinmain()to initiate shutdown.
- You may use
run()to embed Effection into existing async code. run()starts execution immediately; awaiting the returned task only observes completion.
- You must not use
createScope()for normal Effection application code. - You may use
createScope()only for integration between Effection and non-Effection lifecycle management (frameworks/hosts/embedders). - You must observe
destroy()(await/yield*) to complete teardown. Callingdestroy()without observation does not guarantee shutdown completion.
- Use
yield* useScope()to capture the currentScopefor integration (e.g. callbacks) and re-enter Effection withscope.run(() => operation).
Shape (canonical)
const op = spawn(myOperation); // returns an OPERATION
const task = yield * op; // returns a TASK (Future) and starts itRules
spawn()does not start work by itself. Yielding the spawn operation starts work.- A spawned task must not outlive its parent scope.
Rules
task.halt()returns aFuture<void>. You must observe it (await/yield*/.then()), or shutdown is not guaranteed to complete.halt()represents teardown. It can succeed even if the task failed.- If a task is halted before completion, consuming its value (
yield* task/await task) fails withError("halted").
| Concept | Owns lifetime | Owns context |
|---|---|---|
Scope |
✅ | ✅ |
Task |
❌ | ❌ |
Valid APIs
createContext<T>(name, defaultValue?)yield* Context.get()yield* Context.expect()yield* Context.set(value)yield* Context.delete()yield* Context.with(value, operation)
Rules
- You must treat context as scope-local. Children inherit from parents; children may override without mutating ancestors.
- You must not treat context as global mutable state.
Rules
race()accepts an array of operations.- It returns the value of the first operation to complete.
- It halts all losing operations.
Rules
all()accepts an array of operations and evaluates them concurrently.- It returns an array of results in input order.
- If any member errors,
all()errors and halts the other members. - If you need "all operations either complete or error" (no fail-fast), wrap
each member to return a railway-style result (e.g.
{ ok: true, value }/{ ok: false, error }) instead of letting errors escape.
Rules
call()invokes a function that returns a value, promise, or operation.call()does not create a scope boundary and does not delimit concurrency.- If you need to report failures without throwing (e.g. so other work can continue), catch errors and return a railway-style result object instead of letting the error escape.
Rules
lift(fn)returns a function that produces anOperationwhich callsfnwhen interpreted (yield*), not when created.
Rules
- Use
action()to wrap callback-style APIs when you can provide a cleanup function. - You must not claim
action()creates an error or concurrency boundary; it does not.
Rules
until(promise)adapts an already-createdPromiseinto anOperation.- Prefer
until(promise)overcall(() => promise)when you have a promise—it is shorter and clearer. - It does not make the promise cancellable; for cancellable interop, prefer
useAbortSignal()with APIs that acceptAbortSignal.
Rules
- Use
scoped()to create a boundary such that effects created inside do not persist after it returns. - You must use
scoped()(notcall()/action()) when you need boundary semantics.
Shape (ordering matters)
resource(function* (provide) {
try {
yield* provide(value);
} finally {
cleanup();
}
});Rules
- Setup happens before
provide(). - Cleanup must be in
finally(or afterprovide()guarded byfinally) so it runs on return/error/halt. - Teardown can be asynchronous. If cleanup needs async work, express it as an
Operationandyield*it insidefinally(wait for teardown to finish)—do not fire-and-forget cleanup.
Rules
ensure(fn)registers cleanup to run when the current operation shuts down.fnmay returnvoid(sync cleanup) or anOperation(async cleanup).- You should wrap sync cleanup bodies in braces so the function returns
void.
Rules
useAbortSignal()is an interop escape hatch for non-Effection APIs that acceptAbortSignal.- The returned signal is bound to the current scope and aborts when that scope exits (return, error, or halt).
- You should pass the signal to a leaf async API call, not thread it through a nested async stack.
- If the choice is "thread an AbortSignal through a nested async stack" vs "rewrite in Effection", you should prefer rewriting in Effection.
Gotchas
- You must not assume AbortController provides structured-concurrency guarantees. See: https://frontside.com/blog/2025-08-04-the-heartbreaking-inadequacy-of-abort-controller/
- A
Stream<T, TClose>is an operation that yields aSubscription<T, TClose>. - A
Subscriptionis stateful; values are observed viayield* subscription.next().
Rules
on()creates aStreamof events from anEventTarget; listeners are removed on scope exit.once()yields the next matching event as anOperation(it is equivalent to subscribing toon()and taking one value).
Rules
sleep(ms)is cancellable: if the surrounding scope exits, the timer is cleared.interval(ms)is aStreamthat ticks until the surrounding scope exits (cleanup clears the interval).suspend()pauses indefinitely and only resumes when its enclosing scope is destroyed.
Rules
- You must call
yield* each.next()exactly once at the end of every loop iteration. - You must call
yield* each.next()even if the iteration ends withcontinue.
Gotchas
- If you do not call
each.next(), the loop throwsIterationErroron the next iteration.
Shape (ordering matters)
for (let value of yield * each(stream)) {
// ...
yield * each.next();
}| Concept | Send from | Send API | Requires subscribers | Buffering |
|---|---|---|---|---|
Channel |
inside operations | send(): Operation<void> |
yes (otherwise dropped) | per-subscriber while subscribed |
Signal |
outside operations (callbacks) | send(): void |
yes (otherwise no-op) | per-subscriber while subscribed |
Queue |
anywhere (single consumer) | add(): void |
no | buffered (single subscription) |
Rules
- Use
createChannel()to construct aChannel. - Use
Channelfor communication between operations. - You must
yield* channel.send(...)/yield* channel.close(...). - You must assume sends are dropped when there are no active subscribers.
Rules
- Use
createSignal()to construct aSignal. - Use
Signalonly as a bridge from synchronous callbacks into an Effection stream. - You must not use
Signalfor in-operation messaging; useChannelinstead. - You must assume
signal.send(...)is a no-op if nothing is subscribed.
Rules
- Use
createQueue()to construct aQueue. - You may use
Queuewhen you need buffering independent of subscriber timing (single consumer). - You must consume via
yield* queue.next().
Rules
- Use
subscribe(asyncIterator)to adapt anAsyncIteratorto an EffectionSubscription. - Use
stream(asyncIterable)to adapt anAsyncIterableto an EffectionStream. - You must not treat JavaScript async iterables as Effection streams without wrapping.
- You must not use
for awaitinside a generator function. Usestream()to adapt the async iterable, theneach()to iterate.
Shape (async iterable consumption)
for (const item of yield * each(stream(asyncIterable))) {
// ...
yield * each.next();
}Rules
withResolvers()creates anoperationplus synchronousresolve(value)/reject(error)functions.- After resolve/reject, yielding the
operationalways produces the same outcome; calling resolve/reject again has no effect.
Use gitmoji for commit and pull request subjects. For changes to files that direct the behavior of AI such as AGENTS.md or llms.txt use a robot emoji instead of the standard gitmoji for documentation
Before committing any changes to this repository:
- Run
deno fmtto format all changed files - Run
deno lintto check for lint errors (TypeScript files only) - Fix any issues before committing
This applies to all file types that Deno formats (TypeScript, JavaScript,
Markdown, JSON, etc.). The www/ subdirectory follows the same rules.
When creating a pull request, use the template at
.github/pull_request_template.md. The PR description must include:
- Motivation — describe the problem or feature request the PR addresses.
- Approach — provide a brief summary of the changes made.