loop-sdk
v0.7.0
Published
Loop engineering framework for building durable, long-running agentic loops — browser, AI, or both
Maintainers
Readme
loop-sdk
A loop engineering framework for building long-running agentic loops. Steps can be browser actions, AI agent calls, data operations, or anything else — composed into named, resumable sequences with structured logging, checkpointing, events, tool-permission enforcement, and a plugin system.
What is loop engineering?
Loop engineering is the practice of building reliable, long-running agentic loops — turning a one-shot AI call into a repeatable, observable, governable process. Where prompt engineering shapes a single model response, loop engineering shapes the control flow around many model calls: how steps are ordered, how their outputs are verified, how failures retry, how a run resumes after a crash, and how much authority each step is granted.
loop-sdk is a loop engineering framework that gives each of those concerns a first-class primitive — steps, context, checkpoints, events, verify/expect gates, tool allowlists, and git-worktree isolation — so AI agents, browser automation, and data pipelines compose into durable workflows instead of brittle scripts. The engine owns the control flow deterministically; the model is invoked only at the steps that need it, and each step's output and tool access can be constrained in code.
Related concepts: agentic loops, agent orchestration, AI workflow automation, durable / resumable agent workflows, multi-agent pipelines, AI agent frameworks, and the "software factory" pattern for autonomous code generation.
Install
npm install loop-sdk
npm install @ai-sdk/anthropic # or @ai-sdk/openai, etc. — AI SDK 6 (v3 models)Test
npm test # builds, then runs the node:test suite in test/Claude Code skill
skills/loop-sdk/SKILL.md teaches Claude Code how to author and validate
.loop files idiomatically. Install it into any project with one command:
npx loop-sdk install skill # → ./.claude/skills/loop-sdk/SKILL.md (--force to overwrite)Then ask Claude things like "write a .loop that checks my mentions and drafts replies" and it produces valid, idiomatic loop files.
Quick start
import { Loop } from 'loop-sdk'
import { claudeCli } from 'loop-sdk'
const loop = new Loop('research')
loop.step('gather', async (ctx) => {
const result = await claudeCli(ctx, 'List the top 5 JS frameworks in 2026.')
ctx.set('frameworks', result.output)
})
loop.step('summarize', async (ctx) => {
const list = ctx.get('frameworks')
await claudeCli(ctx, `Summarize this list in one sentence: ${list}`)
})
await loop.run({ session: null })Concepts
These are the building blocks of loop engineering with this SDK — the primitives you compose into durable agentic workflows.
Loop
A named sequence of steps. Steps are plain async functions that receive a ctx (Context).
const loop = new Loop('my-loop')
loop.step('step-name', async (ctx) => {
// do work
}, {
retries: 3, // retry this step up to 3 times on failure
retryDelay: 1000, // wait 1s between retries
retryBackoff: 'exponential', // 'flat' | 'linear' | 'exponential'
skipOnError: false, // skip this step instead of failing the loop
onError: async (err, ctx) => {
// fallback: return a value to use as the step result, or re-throw to fail
},
})
await loop.run({ session })loop.run(opts) options:
| Option | Type | Description |
|--------|------|-------------|
| session | Session \| null | The session to run against. |
| vars | object | Initial variables available as ctx.vars. |
| logDir | string | Directory to write a JSON run log. |
| startAt | number | Skip steps before this 1-based index. |
| stopAt | number | Stop after this 1-based index. |
| signal | AbortSignal | External abort signal to cancel the run. |
| checkpointFile | string | Path to write checkpoints after each step. |
| resumeFrom | string | Checkpoint file to resume from (skips completed steps). |
| keepCheckpointOnSuccess | boolean | Keep the checkpoint file after a successful run (default: false). |
| onError | (err, ctx, failedStep) => Promise<void> | Called when the loop fails. |
Returns a run log object: { loop, session, status, steps, startedAt, finishedAt }.
Parallel steps
Run multiple step functions concurrently within a single named step:
loop.parallel('fetch-all', [
async (ctx) => { ctx.set('a', await fetch('https://a.com')) },
async (ctx) => { ctx.set('b', await fetch('https://b.com')) },
async (ctx) => { ctx.set('c', await fetch('https://c.com')) },
])All functions in a parallel group share the same context and run via Promise.all.
Background execution
runBackground() starts the loop in the background and immediately returns a RunHandle.
const handle = loop.runBackground({ session })
console.log(handle.id) // unique run ID
console.log(handle.status) // 'running' | 'completed' | 'failed' | 'cancelled' | 'paused'
const log = await handle.wait() // resolves when the loop finishes
handle.cancel() // cancel the loop
handle.pause() // pause after the current step
const newHandle = handle.resume() // resume from where it pausedCheckpoints are auto-written under .loop/<id>.checkpoint.json during background runs. Pause and resume use the checkpoint file automatically.
Run multiple loops in parallel:
const handles = await Loop.runAll([
{ loop: loopA, opts: { session } },
{ loop: loopB, opts: { session } },
{ loop: loopC, opts: { session } },
])Checkpointing
Checkpoints save progress after each step so a loop can be resumed after a crash or pause.
await loop.run({
session,
checkpointFile: '.loop/my-run.checkpoint.json',
})
// Later, resume from where it left off:
await loop.run({
session,
resumeFrom: '.loop/my-run.checkpoint.json',
})Helpers:
import { checkpointExists, deleteCheckpoint } from 'loop-sdk'
if (await checkpointExists('.loop/my-run.checkpoint.json')) {
// resume
}
await deleteCheckpoint('.loop/my-run.checkpoint.json')Checkpoint files are auto-deleted on successful completion unless keepCheckpointOnSuccess: true.
Idempotent effects
Use loop.effect() for external writes such as sending a message, creating a
ticket, or charging an account. The supplied key is recorded in the checkpoint
before the effect starts. On resume, a completed effect returns its saved result
without calling the external service again; an interrupted one is retried with
the same key. Pass that key to the external API's idempotency mechanism.
loop.effect('create-ticket', async (ctx, key) => {
return jira.createIssue({ summary: ctx.vars.summary, idempotencyKey: key })
}, {
key: ctx => `ticket:${ctx.vars.customerId}:${ctx.vars.requestId}`,
compensate: async (ticket, _ctx, key) => {
await jira.deleteIssue(ticket.id, { idempotencyKey: `undo:${key}` })
},
})Effects are durable when the run has a checkpointFile (including
runBackground(), which creates one automatically). The completed result is
also available as ctx.get('create-ticket').
Run npm run demo:effects for a self-contained example of resuming a completed
effect and rolling back a failed saga.
Set compensateOnError: true on loop.run() to run completed effects' optional
compensate functions in reverse order after an unrecovered failure. A
compensation is itself checkpointed before and after it runs, and receives the
original key so downstream rollback APIs can be idempotent too.
Events
Loops emit typed events throughout their lifecycle. Listen with loop.on() / loop.off().
loop.on('loop:start', ({ totalSteps }) => {
console.log(`Starting with ${totalSteps} steps`)
})
loop.on('loop:complete', ({ status, durationMs, stepsCompleted }) => {
console.log(`Finished: ${status} in ${durationMs}ms`)
})
loop.on('step:start', ({ step, index }) => { })
loop.on('step:complete', ({ step, durationMs }) => { })
loop.on('step:error', ({ step, error, attempt }) => { })
loop.on('step:skip', ({ step, reason }) => { }) // reason: 'checkpoint' | 'range' | 'error'
loop.on('step:retry', ({ step, attempt, delay }) => { })
loop.on('checkpoint:saved', ({ file, completedSteps }) => { })
loop.on('log', ({ message }) => { }) // ctx.log lines, attributed per run
loop.on('usage', ({ costUsd, inputTokens, outputTokens }) => { }) // claudeCli spend, cumulative per step
loop.on('agent', ({ kind, text }) => { }) // live claudeCli transcript: text | tool_use | tool_result | error
loop.on('worktree:created', ({ path, branch, baseRef }) => { })Custom events — emit from inside a step and listen anywhere:
loop.step('draft', async (ctx) => {
ctx.set('draft', '...')
ctx.emit('draft:ready', { preview: ctx.get('draft') })
})
loop.on('draft:ready', async ({ preview }) => {
// approve, notify, update UI, etc.
})Context
Passed to every step. Carries shared state, per-iteration variables, and browser shortcuts.
// State — shared across all steps in a run
ctx.set('key', value)
ctx.get('key')
ctx.has('key')
ctx.snapshot() // plain object of all state
// Variables — injected per-iteration by each()
ctx.vars.item
ctx.vars.subtype
// Emit a custom event
ctx.emit('my:event', { data: 123 })
// Logging
ctx.log('message', { optional: 'data' })
// Browser shortcuts (delegate to ctx.session)
ctx.navigate(url)
ctx.click({ selector, text, x, y })
ctx.type(text)
ctx.key('Enter')
ctx.scroll({ deltaY: 300 })
ctx.screenshot() // returns Buffer
// Direct session access
ctx.session.mcp('browser_evaluate', { function: '() => document.title' })Session
Abstract interface that all browser providers implement. Extend it to add your own:
import { Session } from 'loop-sdk'
export class MySession extends Session {
async navigate(url) { /* ... */ }
async click(opts) { /* ... */ }
async type(text) { /* ... */ }
async key(key) { /* ... */ }
async scroll(opts) { /* ... */ }
async screenshot() { /* returns Buffer */ }
async destroy() { /* ... */ }
// Optional: expose an MCP URL so agent() / claudeCli() gets browser tool access
get mcpUrl() { return `http://my-daemon/sessions/${this.id}/mcp` }
}agent()
Run any AI model as a loop step. Uses the Vercel AI SDK (v6 — LanguageModelV3) so every @ai-sdk/* provider works. A provider built for a different AI SDK major fails fast with a directed error rather than a cryptic "unsupported model version".
model takes either a LanguageModel object or a friendly "provider:model" string resolved by the built-in registry — the same string works in JS and in a .loop file:
import { agent } from 'loop-sdk'
import { anthropic } from '@ai-sdk/anthropic'
loop.step('summarize', async (ctx) => {
const result = await agent(ctx, 'Summarize the main content of this page.', {
model: anthropic('claude-opus-4-8'), // an object …
// model: 'anthropic:claude-opus-4-8', // … or the equivalent string
screenshot: true, // attach a screenshot before calling the model
maxSteps: 50,
system: 'You are a research assistant.',
})
ctx.set('summary', result.text)
})Model strings follow the AI SDK's provider:model convention. Built-in providers:
| Prefix | Package (optional peer) | Auth |
|--------|-------------------------|------|
| claude-code: (alias claude:) | ai-sdk-provider-claude-code | Claude Pro/Max subscription — no API key |
| codex: | ai-sdk-provider-codex-cli | ChatGPT subscription or API key |
| anthropic: | @ai-sdk/anthropic | API key |
| openai: | @ai-sdk/openai | API key |
A bare id (no prefix) uses the default provider, claude-code — so 'sonnet' means 'claude-code:sonnet'. Each provider is an optional dependency: a string only needs the one package that backs it installed, and a missing one fails with a directed npm i … hint. Register your own with registerProvider('id', (modelId) => model).
The two CLI-backed providers (
claude-code,codex) run their own tool-use loop and ignore AI SDK tools — soctx.session.mcpUrlbrowser tools don't reach them. To give them MCP tools, set the step'smcp:field (registry names or inline defs, incl. remote HTTP servers) — it's handed to the provider asmcpServers. Permissions follow the loop'smode:explore(default) runs frictionless so tool calls aren't blocked;strictenforces the declaredtools:allowlist. For browser work with Claude Code specifically, aclaudeClistep wires the browser MCP through Claude Code's own config. With an API provider (anthropic:/openai:),agent()gets the session's browser tools via MCP automatically.
claudeCli()
Spawn the claude CLI subprocess (claude -p) as a loop step. Use this when you want Claude Code's built-in tool-use loop, retry logic, and MCP permission model.
Requires claude to be installed and on PATH.
import { claudeCli } from 'loop-sdk'
loop.step('fill', async (ctx) => {
const result = await claudeCli(ctx, 'Fill out the visible form fields with test data.', {
screenshot: true,
model: 'claude-opus-4-8',
timeout: 240_000,
tools: ['Read', 'Edit'], // scope the step to an allowlist
enforce: true, // enforce it (deny unlisted tools) instead of skipping permissions
})
ctx.set('output', result.output)
})agent() vs claudeCli():
| | agent() | claudeCli() |
|-|-----------|---------------|
| Provider | Any @ai-sdk/* model or "provider:model" string | Claude CLI only |
| Tool-use loop | Managed by Vercel AI SDK | Managed by Claude Code CLI |
| Best for | Multi-provider, programmatic control | Delegating full control to Claude |
each()
Iterate over a list of items. Each item gets its own forked context with per-item vars.
import { each } from 'loop-sdk'
loop.step('process-pages', async (ctx) => {
const pages = ['https://a.com', 'https://b.com', 'https://c.com']
await each(ctx, pages, async (ctx) => {
await ctx.navigate(ctx.vars.item)
await claudeCli(ctx, 'Summarize this page.')
}, { continueOnError: true })
})Items can carry subtypes:
const items = [
{ type: 'Category A', subtypes: ['Sub 1', 'Sub 2'] },
'Category B',
]
await each(ctx, items, async (ctx) => {
console.log(ctx.vars.item, ctx.vars.subtype)
// → 'Category A', 'Sub 1'
// → 'Category A', 'Sub 2'
// → 'Category B', undefined
})sub()
Run a Loop as a sub-step, sharing the current session and state.
import { sub } from 'loop-sdk'
const loginLoop = new Loop('login')
loginLoop.step('authenticate', async (ctx) => {
await claudeCli(ctx, `Log in as ${ctx.vars.username}.`)
})
loop.step('login', async (ctx) => {
await sub(ctx, loginLoop, { username: 'admin' })
})State written inside the sub-loop is visible in the parent loop (shared ctx state Map).
Plugins
Plugins hook into the loop lifecycle. Return true from onStepError to retry the failed step.
const crashRecovery = {
name: 'crash-recovery',
hooks: {
onStepError: async (err, step, ctx) => {
if (!err.message.includes('context was lost')) return false
await ctx.session.recreate()
return true // retry the step
},
},
}
loop.use(crashRecovery)RetryPlugin
Built-in plugin for automatic step retries with backoff:
import { RetryPlugin } from 'loop-sdk'
loop.use(RetryPlugin({
attempts: 3,
delay: 500,
backoff: 'exponential', // 'flat' | 'linear' | 'exponential'
retryIf: (err) => !err.message.includes('auth'), // optional filter
}))macOS Notifications
Send native macOS notifications from a loop:
import { notify, notifyOn } from 'loop-sdk'
// One-off notification
notify('Step completed!', { title: 'My Loop', sound: true })
// Auto-wire lifecycle notifications to a loop
notifyOn(loop, {
onStart: true,
onComplete: true,
onError: true,
onStepError: false,
title: 'My Loop',
sound: true,
})No-op on non-macOS platforms.
.loop files
.loop files let you define loops in a simple text format. An AI agent can generate them via MCP; your app parses and runs them.
Format:
---
name: my-loop
session: browser # this loop needs a browser session
browserMode: extension # 'isolated' (default) | 'chrome' (CDP) | 'extension'
browserProfile: "{{profile}}" # which Chrome profile/identity acts ({{vars}} ok)
model: claude-sonnet-5 # default model for claudeCli/verify steps
mode: strict # 'explore' (default) | 'strict' — enforcement posture (see below)
tools: # default tool allowlist for worker steps (a step's own `tools:` wins)
- Read
- Edit
- Bash
workdir: ~/Code/my-repo # cwd for claudeCli/verify (a repo makes them build workers)
worktree: true # isolate each run's changes in a git worktree + branch
onSuccess: pr # runner action after success: keep | merge | pr
mcp: # MCP servers attached to this loop's Claude workers
- boards
reflexion: true # failed verify retries the prior prompt step once with the critique
vars: # declared inputs and their defaults
city: Austin
---
## step-one
action: claudeCli
prompt: Write a haiku about {{city}}.
## check
action: verify
assert: "{{step-one}} is a haiku (5-7-5)"
## step-two
action: log
message: Done! Output was {{step-one}}Supported actions:
| Action | Description |
|--------|-------------|
| claudeCli | Run claude -p with prompt (model, maxSteps, screenshot, workdir, mcp, tools, expect per step). |
| codexCli | Run the OpenAI Codex CLI with prompt (model, expect, mcp). MCP servers are injected as Codex -c mcp_servers.* config overrides. |
| agent | Run any AI SDK model via agent(). model is a registry string like claude-code:sonnet, codex:gpt-5.2-codex, or anthropic:claude-opus-4-8 (falls back to meta.model); also prompt, system, maxSteps, screenshot, expect, and mcp: (servers handed to CLI-backed providers as mcpServers; permission posture follows the loop's mode). |
| verify | AI judge checks assert; failure fails the step (and triggers reflexion). |
| send | Push a message to the user — channel: imessage (with to) or ntfy (with topic). |
| navigate / click / type / key / scroll / screenshot | Browser actions on the run's session. |
| wait | Pause ms milliseconds. |
| log | Emit message to the run log. |
| set-variable | Store value under key (referenceable as {{key}} or the step name). |
| parallel | Run nested steps concurrently. |
| subloop | Run a nested loop as one step, sharing context; pass vars, collect output. Body is either a loop: file path or inline steps: (self-contained, no separate file). Nesting is guarded: a circular loop: reference or runaway depth (> MAX_SUBLOOP_DEPTH, 50) fails fast with a clear error instead of hanging. (Alias: sub, deprecated.) |
| each | Iterate items (array, {{ref}}, or lines) over steps/loop; as names the item var; concurrency: 1-8 runs items in parallel with isolated state; continueOnError. |
| custom | Any action name registered via the actions map passed to loadLoop() — how runners add steps like approve or card. |
Validation (fail fast) — loadLoop() validates the file before running any step and throws an aggregated error listing every problem: unknown actions (with a "did you mean…?" suggestion), duplicate or un-referenceable step names (names must be kebab-case to be reachable via {{…}}), and missing required fields. So a typo in step 5 is caught at load, not after steps 1–4 have run their side effects. Call validateLoopSchema(parseLoopFile(src), customActions?) yourself for editor/lint integration — it returns a string[] of problems (empty = valid).
{{name}} interpolation — resolves prior step outputs first, then vars (declared inputs, run-time vars, each-item aliases). Unresolved refs warn and keep the literal; use describeLoop() to catch them before running.
Reflexion — when a verify step fails and the step before it is a prompt step, that step is retried once with the judge's critique appended, then re-verified. Opt out with reflexion: false.
Enforcement (mode / tools / expect) — the loop engineering primitives for making a run governable: how you turn "follow the steps" from best-effort into a guarantee. The control flow already lives in the engine (not the model); these three knobs constrain what each worker step may do and return:
mode—explore(default) runs workers frictionless: permissions are skipped unless a step declares its owntools:.strictscopes every worker step to an allowlist and denies unlisted tools. Whenmodeis unset it auto-escalates tostrictfor loops that ship hard-to-reverse changes (worktree, oronSuccess: merge|pr) — set it explicitly to override. The default path is unchanged from prior versions.tools— a tool allowlist (Claude Code names, e.g.[Read, Edit, Bash]) at the loop level or per step. Declaring an allowlist enforces it regardless ofmode— if you list tools, unlisted ones are denied. A live browser session automatically keeps itsmcp__browsertools.expect— a deterministic output contract checked in code after a step runs, for any action (not justclaudeCli):json/non-empty, or an object{ json, nonEmpty, contains, matches }. If it doesn't hold, the step fails. Prefer this over averifyAI-judge whenever the check is mechanical — it's a true guarantee, not a probabilistic one.
## extract
action: claudeCli
prompt: Return the posts as a JSON array. Output ONLY JSON.
tools: [Read] # this step may only read files
expect: json # hard-fails unless the output parses as JSONRunning a .loop file — the CLI:
The fastest way to run a browserless loop (claudeCli / codexCli / verify / data steps):
npx loop-sdk run research.loop
npx loop-sdk run reply.loop --var topic=AI --var tone=casual
npx loop-sdk run research.loop --json # print the run log as JSON to stdoutThe CLI runs the loop with a built-in no-op session, streams step progress, and
exits non-zero if the run fails. claudeCli steps require the claude CLI on
PATH. Loops that declare session: browser need a browser provider — run those
via the JS API below with your own Session.
Running a .loop file — the JS API:
import { loadLoop, runFile, runFileBackground, NullSession } from 'loop-sdk'
// Browserless loops use the built-in no-op session; browser loops pass a
// provider like PlaywrightSession (or your own Session) instead.
const session = new NullSession('run')
// Run synchronously (returns run log)
await runFile('./my-loop.loop', session)
// Run in the background (returns RunHandle)
const handle = runFileBackground('./my-loop.loop', session)
await handle.wait()
// Parse and build a Loop instance manually — with custom actions and a
// runtime overlay (LoadOptions) for things like test runs
const loop = loadLoop('./my-loop.loop', {
approve: async (ctx, step) => { /* pause for human approval */ },
}, {
maxTurnsCap: 10, // clamp agentic turns on claudeCli/verify steps
skipNotify: true, // mute the loop's notify config
})
await loop.run({ session, vars: { city: 'Denver' } })Front-matter vars: are applied as input defaults by the engine (Loop.defaults()), so declared inputs work even when the host passes nothing; provided vars win.
describeLoop()
Static analysis for runners — everything you need to know before executing:
import { describeLoop } from 'loop-sdk'
const desc = describeLoop(content, deps /* optional: { 'child.loop': contents } */)
// {
// name, mode: 'explore'|'strict', // effective posture (incl. auto-escalation)
// needsBrowser, browserMode: 'launch'|'cdp'|'extension',
// browserProfile, cdpUrl, mcp,
// inputs: { city: 'Austin' }, // declared defaults
// stepNames: [...],
// referencedVars: ['post-url'], // {{refs}} with NO local source —
// // supply them at run time or refuse
// reachableDeps: ['child.loop'], // transitive sub/each references
// }Browser requirements propagate only through deps the entry loop actually
references — an unrelated browser loop in the dep map doesn't force a browser.
Use referencedVars for pre-flight validation: refuse the run with a clear
message instead of letting literal {{braces}} leak into navigation and prompts.
Worktrees & MCP registry
worktree: true+workdir— each run's file changes land in an isolated git worktree on branchloop/<name>-<id>(ensureWorktree); parallel runs against one repo never collide. The runner decides what happens to the branch afterward (onSuccess: keep | merge | pr).mcp:— names are resolved from~/.loopdeloop/mcp.json(Claude CodemcpServersformat) at step runtime viaresolveMcpServers(); inline definitions also work. Step-levelmcp:overrides the loop's.
MCP server
The loop-mcp server lets AI agents create and manage .loop files via MCP tools.
Start it:
npx loop-mcp
# or
npm run mcpAvailable tools:
| Tool | Description |
|------|-------------|
| write_loop | Write (or overwrite) a .loop file. Validates before writing. |
| read_loop | Read the contents of a .loop file. |
| list_loops | List all .loop files with step summaries. |
| validate_loop | Validate a .loop file without writing it. |
Loop files are stored in the directory set by LOOP_DIR (default: .loop).
Claude Desktop config:
{
"mcpServers": {
"loop": {
"command": "npx",
"args": ["loop-mcp"]
}
}
}PlaywrightSession
The built-in browser provider. Wraps a Playwright browser daemon over its HTTP API (default http://localhost:4848).
import { PlaywrightSession } from 'loop-sdk/playwright'
const session = new PlaywrightSession('session-id', {
daemon: 'http://localhost:4848', // default
})
await session.ensure() // create if it doesn't exist
await session.recreate() // destroy + re-create (after a crash)
await session.destroy() // tear down
// Extra methods:
await session.evaluate('() => document.title')
await session.currentUrl()
await session.mcp('browser_snapshot', {})Run log
When logDir is passed to loop.run(), a JSON log is written incrementally:
{
"loop": "my-loop",
"session": "my-session",
"status": "completed",
"startedAt": "2026-06-28T10:00:00.000Z",
"finishedAt": "2026-06-28T10:01:23.456Z",
"steps": [
{ "name": "gather", "status": "ok" },
{ "name": "summarize", "status": "ok" }
]
}Possible statuses: completed, failed, running (if the process was killed mid-run), cancelled.
Writing a custom provider
Any class that extends Session works as a provider:
import { Session } from 'loop-sdk'
import puppeteer from 'puppeteer'
export class PuppeteerSession extends Session {
constructor(id) {
super(id)
this._browser = null
this._page = null
}
async ensure() {
this._browser = await puppeteer.launch()
this._page = await this._browser.newPage()
}
async navigate(url) { await this._page.goto(url) }
async click({ selector }) { await this._page.click(selector) }
async type(text) { await this._page.keyboard.type(text) }
async key(key) { await this._page.keyboard.press(key) }
async scroll({ deltaY }) { await this._page.evaluate(dy => window.scrollBy(0, dy), deltaY) }
async screenshot() { return this._page.screenshot({ type: 'jpeg' }) }
async destroy() { await this._browser?.close() }
}
const session = new PuppeteerSession('puppeteer-session')
await session.ensure()
await loop.run({ session })
await session.destroy()