create-loopengine 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evan Yan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ export interface ScaffoldOptions {
3
+ name: string;
4
+ /** Defaults to `./<name>`. */
5
+ destinationDir?: string;
6
+ templateDir?: string;
7
+ }
8
+ export declare function scaffold(options: ScaffoldOptions): string;
9
+ export declare function main(argv?: string[]): void;
package/dist/cli.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ // Template ships in `template/` next to `dist/`, not inside it — it's a
6
+ // real, buildable project (its own package.json/tsconfig), not TypeScript
7
+ // source belonging to this CLI.
8
+ const DEFAULT_TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'template');
9
+ export function scaffold(options) {
10
+ const { name } = options;
11
+ const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR;
12
+ const destination = options.destinationDir ?? name;
13
+ if (existsSync(destination)) {
14
+ throw new Error(`${destination} already exists — remove it or choose a different name.`);
15
+ }
16
+ mkdirSync(destination, { recursive: true });
17
+ cpSync(templateDir, destination, { recursive: true });
18
+ const pkgPath = join(destination, 'package.json');
19
+ const pkg = readFileSync(pkgPath, 'utf8').replace('__PROJECT_NAME__', name);
20
+ writeFileSync(pkgPath, pkg);
21
+ const readmePath = join(destination, 'README.md');
22
+ const readme = readFileSync(readmePath, 'utf8').replace('__PROJECT_NAME__', name);
23
+ writeFileSync(readmePath, readme);
24
+ return destination;
25
+ }
26
+ function parseArgs(argv) {
27
+ return { name: argv[0] ?? 'my-agents' };
28
+ }
29
+ export function main(argv = process.argv.slice(2)) {
30
+ try {
31
+ const { name } = parseArgs(argv);
32
+ const destination = scaffold({ name });
33
+ console.log(`Created ${destination}/`);
34
+ console.log();
35
+ console.log(` cd ${destination}`);
36
+ console.log(' npm install');
37
+ console.log(' cp .env.example .env # fill in ANTHROPIC_API_KEY');
38
+ console.log(' npm run dev');
39
+ }
40
+ catch (err) {
41
+ console.error(err instanceof Error ? err.message : String(err));
42
+ process.exitCode = 1;
43
+ }
44
+ }
45
+ // A package-manager shim (npx, node_modules/.bin) invokes this file through
46
+ // a symlink, not its real path — process.argv[1] is the symlink's path,
47
+ // while Node resolves import.meta.url to the real path when loading an ES
48
+ // module. Comparing the raw argv[1] against the resolved module URL fails
49
+ // in exactly that case and main() silently never runs — realpath argv[1]
50
+ // first so both sides are resolved. (Same bug, same fix, as skillgarden's
51
+ // CLI — see its git history for the live symlink repro.)
52
+ function isMain() {
53
+ if (!process.argv[1])
54
+ return false;
55
+ try {
56
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ if (isMain())
63
+ main();
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "create-loopengine",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/evanyan22/create-loopengine.git"
10
+ },
11
+ "bin": {
12
+ "create-loopengine": "dist/cli.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "template",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc && chmod +x dist/cli.js",
24
+ "prepublishOnly": "npm run build",
25
+ "test": "vitest run"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.10.0",
29
+ "tsx": "^4.19.0",
30
+ "typescript": "^5.7.0",
31
+ "vitest": "^2.1.0"
32
+ }
33
+ }
@@ -0,0 +1,10 @@
1
+ ANTHROPIC_API_KEY=
2
+ # Using OpenAI instead? Swap agents/example-agent.ts's createModelCall for
3
+ # createOpenAIModelCall({ model: '...' }) and set this instead:
4
+ # OPENAI_API_KEY=
5
+
6
+ PORT=8787
7
+
8
+ # Optional — omit for local, file-based sessions under .sessions/. Set this
9
+ # once you run more than one server instance against the same sessions.
10
+ # REDIS_URL=redis://localhost:6379
@@ -0,0 +1,49 @@
1
+ # __PROJECT_NAME__
2
+
3
+ An agent server built with [loopengine](https://www.npmjs.com/package/loopengine).
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ npm install
9
+ cp .env.example .env # fill in ANTHROPIC_API_KEY
10
+ ```
11
+
12
+ ## Run
13
+
14
+ ```bash
15
+ npm run dev # HTTP server on :8787
16
+ ```
17
+
18
+ ```bash
19
+ curl -X POST localhost:8787/agents/example-agent/messages \
20
+ -H 'content-type: application/json' \
21
+ -d '{"sessionId":"s1","message":"what is the weather in Boston?"}'
22
+ ```
23
+
24
+ Or from the command line directly, no server needed:
25
+
26
+ ```bash
27
+ npm run cli -- --agent example-agent "what is the weather in Boston?"
28
+ ```
29
+
30
+ ## Add another agent
31
+
32
+ 1. Copy `agents/example-agent.ts` to `agents/your-agent.ts` and edit its
33
+ `systemPrompt`, `tools`, and `rules`.
34
+ 2. Register it in `agent-registry.ts` — one line, same shape as
35
+ `example-agent`.
36
+ 3. Call it at `/agents/your-agent/messages`.
37
+
38
+ ## Project layout
39
+
40
+ ```
41
+ agent-registry.ts Maps agent name -> {config, createModelCall}
42
+ agents/example-agent.ts Starter agent: persona, one tool, one permission rule
43
+ adapters/http.ts HTTP API (streaming + non-streaming)
44
+ adapters/cli.ts Command-line adapter
45
+ ```
46
+
47
+ See [loopengine's own README](https://github.com/evanyan22/loopengine) for
48
+ the full `AgentConfig` surface, permission rules, skills, and how to wire a
49
+ real model.
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // Channel adapter #1: command line.
3
+ //
4
+ // npm run cli -- --agent example-agent "what's the weather in Boston?"
5
+ //
6
+ // Omit --session for a one-off ask — each call gets its own fresh,
7
+ // isolated session (a random id, printed to stderr so it can be reused
8
+ // with --session <id> later to continue that conversation).
9
+ import { randomUUID } from 'node:crypto'
10
+ import { createSessionStore, runAgent } from 'loopengine'
11
+ import { getEntry, listAgents } from '../agent-registry.js'
12
+
13
+ function parseArgs(argv: string[]) {
14
+ let agent = ''
15
+ let session: string | undefined
16
+ const rest: string[] = []
17
+ for (let i = 0; i < argv.length; i++) {
18
+ if (argv[i] === '--agent') agent = argv[++i]
19
+ else if (argv[i] === '--session') session = argv[++i]
20
+ else rest.push(argv[i])
21
+ }
22
+ return { agent, session, message: rest.join(' ') }
23
+ }
24
+
25
+ async function main() {
26
+ const { agent, session, message } = parseArgs(process.argv.slice(2))
27
+ const entry = getEntry(agent)
28
+ if (!entry) {
29
+ console.error(`unknown agent '${agent}'. available: ${listAgents().join(', ')}`)
30
+ process.exit(1)
31
+ }
32
+ if (!message) {
33
+ console.error('usage: cli.ts --agent <name> [--session <id>] "<message>"')
34
+ process.exit(1)
35
+ }
36
+
37
+ const sessionId = session ?? randomUUID()
38
+ if (!session) console.error(`[session] ${sessionId} (pass --session ${sessionId} to continue this conversation)`)
39
+
40
+ const sessions = createSessionStore()
41
+ try {
42
+ // Namespaced by agent name so the same --session id reused across two
43
+ // different agents doesn't read/write the same underlying log.
44
+ const text = await sessions.withSession(`${agent}:${sessionId}`, async (history) => {
45
+ const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
46
+ onEvent: (event, detail) => console.error(`[${event}]`, detail),
47
+ })
48
+ return { newMessages: result.newMessages, result: result.text }
49
+ })
50
+ console.log(text)
51
+ } finally {
52
+ await sessions.close()
53
+ }
54
+ }
55
+
56
+ main()
@@ -0,0 +1,161 @@
1
+ // Channel adapter #2: HTTP API.
2
+ //
3
+ // npm run dev
4
+ //
5
+ // curl -X POST localhost:8787/agents/example-agent/messages \
6
+ // -H 'content-type: application/json' \
7
+ // -d '{"sessionId":"s1","message":"what is the weather in Boston?"}'
8
+ //
9
+ // # same request, but as it happens: one SSE event per loop step
10
+ // curl -N -X POST localhost:8787/agents/example-agent/messages/stream \
11
+ // -H 'content-type: application/json' \
12
+ // -d '{"sessionId":"s1","message":"what is the weather in Boston?"}'
13
+ //
14
+ // Owns: routing by agent name, request/response shape, and (via
15
+ // SessionStore.withSession) making sure two concurrent requests for the
16
+ // same session don't race on read-modify-write of that session's history.
17
+ import { randomUUID } from 'node:crypto'
18
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
19
+ import { createSessionStore, runAgent, type AgentConfig } from 'loopengine'
20
+ import { getEntry, type RegistryEntry } from '../agent-registry.js'
21
+
22
+ const sessions = createSessionStore()
23
+
24
+ // Deriving a session key from something richer than a plain client-supplied
25
+ // id (a customer's email, a Slack channel, ...) is business logic specific
26
+ // to what an agent is for — see AgentConfig.sessionIdFor. This is only the
27
+ // agent-agnostic fallback: a missing sessionId just means "no ongoing
28
+ // conversation to resume," so one is generated rather than treated as an error.
29
+ function defaultSessionIdFor(body: Record<string, unknown>): string | undefined {
30
+ const sessionId = body.sessionId
31
+ return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined
32
+ }
33
+
34
+ function sessionIdFor(config: AgentConfig, body: Record<string, unknown>): string | undefined {
35
+ if (config.sessionIdFor) return config.sessionIdFor(body)
36
+ return defaultSessionIdFor(body) ?? randomUUID()
37
+ }
38
+
39
+ function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
40
+ return new Promise((resolve, reject) => {
41
+ let data = ''
42
+ req.on('data', (chunk) => (data += chunk))
43
+ req.on('end', () => {
44
+ try {
45
+ resolve(data ? JSON.parse(data) : {})
46
+ } catch (err) {
47
+ reject(err)
48
+ }
49
+ })
50
+ req.on('error', reject)
51
+ })
52
+ }
53
+
54
+ interface ParsedRequest {
55
+ entry: RegistryEntry
56
+ message: string
57
+ rawSessionId: string
58
+ storageSessionId: string
59
+ }
60
+
61
+ type ParseResult = { ok: true; value: ParsedRequest } | { ok: false; status: number; error: string }
62
+
63
+ async function parseRequest(req: IncomingMessage, agentName: string): Promise<ParseResult> {
64
+ const entry = getEntry(agentName)
65
+ if (!entry) return { ok: false, status: 404, error: `unknown agent '${agentName}'` }
66
+
67
+ const body = await readJsonBody(req)
68
+ const message = String(body.message ?? '')
69
+ const rawSessionId = sessionIdFor(entry.config, body)
70
+ if (!message) return { ok: false, status: 400, error: 'message is required' }
71
+ if (!rawSessionId) return { ok: false, status: 400, error: 'could not derive a session id from the request body' }
72
+
73
+ // Namespaced by agent name so two agents given the same sessionId don't
74
+ // read/write the same underlying log.
75
+ return { ok: true, value: { entry, message, rawSessionId, storageSessionId: `${agentName}:${rawSessionId}` } }
76
+ }
77
+
78
+ function writeSseEvent(res: ServerResponse, event: string, data: unknown): void {
79
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
80
+ }
81
+
82
+ async function handleMessages(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
83
+ const parsed = await parseRequest(req, agentName)
84
+ if (!parsed.ok) {
85
+ res.writeHead(parsed.status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
86
+ return
87
+ }
88
+ const { entry, message, rawSessionId, storageSessionId } = parsed.value
89
+
90
+ const text = await sessions.withSession(storageSessionId, async (history) => {
91
+ const result = await runAgent(entry.config, entry.createModelCall(), message, history)
92
+ return { newMessages: result.newMessages, result: result.text }
93
+ })
94
+
95
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ text, sessionId: rawSessionId }))
96
+ }
97
+
98
+ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
99
+ const parsed = await parseRequest(req, agentName)
100
+ if (!parsed.ok) {
101
+ res.writeHead(parsed.status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
102
+ return
103
+ }
104
+ const { entry, message, rawSessionId, storageSessionId } = parsed.value
105
+
106
+ res.writeHead(200, {
107
+ 'content-type': 'text/event-stream',
108
+ 'cache-control': 'no-cache',
109
+ connection: 'keep-alive',
110
+ })
111
+ writeSseEvent(res, 'session', { sessionId: rawSessionId })
112
+
113
+ try {
114
+ await sessions.withSession(storageSessionId, async (history) => {
115
+ const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
116
+ onEvent: (event, detail) => writeSseEvent(res, event, detail),
117
+ })
118
+ writeSseEvent(res, 'done', { text: result.text })
119
+ return { newMessages: result.newMessages, result: result.text }
120
+ })
121
+ } catch (err) {
122
+ writeSseEvent(res, 'error', { error: String(err) })
123
+ } finally {
124
+ res.end()
125
+ }
126
+ }
127
+
128
+ const server = createServer(async (req, res) => {
129
+ try {
130
+ const streamMatch = req.method === 'POST' && req.url?.match(/^\/agents\/([^/]+)\/messages\/stream$/)
131
+ if (streamMatch) {
132
+ await handleMessagesStream(req, res, decodeURIComponent(streamMatch[1]))
133
+ return
134
+ }
135
+
136
+ const match = req.method === 'POST' && req.url?.match(/^\/agents\/([^/]+)\/messages$/)
137
+ if (!match) {
138
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not found' }))
139
+ return
140
+ }
141
+
142
+ await handleMessages(req, res, decodeURIComponent(match[1]))
143
+ } catch (err) {
144
+ if (res.headersSent) {
145
+ res.end()
146
+ } else {
147
+ res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: String(err) }))
148
+ }
149
+ }
150
+ })
151
+
152
+ const port = Number(process.env.PORT ?? 8787)
153
+ server.listen(port, () => console.log(`agent API listening on :${port}`))
154
+
155
+ async function shutdown() {
156
+ server.close()
157
+ await sessions.close()
158
+ process.exit(0)
159
+ }
160
+ process.on('SIGTERM', shutdown)
161
+ process.on('SIGINT', shutdown)
@@ -0,0 +1,24 @@
1
+ // Every adapter resolves "which agent" through this — the thing that
2
+ // makes agents pluggable. Defining a new agent means adding one line here;
3
+ // no adapter changes needed.
4
+ import type { AgentConfig, ModelCall } from 'loopengine'
5
+ import { config as exampleAgentConfig, createModelCall as createExampleAgentModelCall } from './agents/example-agent.js'
6
+
7
+ export interface RegistryEntry {
8
+ config: AgentConfig
9
+ /** Factory, not a shared instance — a fresh ModelCall per request/session. */
10
+ createModelCall: () => ModelCall
11
+ }
12
+
13
+ const entries: Record<string, RegistryEntry> = {
14
+ 'example-agent': { config: exampleAgentConfig, createModelCall: createExampleAgentModelCall },
15
+ }
16
+
17
+ export function listAgents(): string[] {
18
+ return Object.keys(entries)
19
+ }
20
+
21
+ /** undefined for an unknown agent name. */
22
+ export function getEntry(name: string): RegistryEntry | undefined {
23
+ return entries[name]
24
+ }
@@ -0,0 +1,26 @@
1
+ // A starting point, not a template to copy verbatim: real persona, real
2
+ // tool, real permission rule. Add more agents by copying this file and
3
+ // registering the new one in agent-registry.ts.
4
+ import { createAnthropicModelCall, type AgentConfig, type ModelCall } from 'loopengine'
5
+
6
+ export const config: AgentConfig = {
7
+ name: 'example-agent',
8
+ systemPrompt: 'You are a helpful assistant.',
9
+ tools: [
10
+ {
11
+ name: 'get_weather',
12
+ description: 'Look up the weather for a city',
13
+ input_schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
14
+ execute: async (input) => `Sunny in ${input.city}`,
15
+ },
16
+ ],
17
+ rules: [{ scopePattern: 'default/production/example-agent', tool: 'get_weather', decision: 'allow' }],
18
+ defaultDecision: 'ask',
19
+ }
20
+
21
+ // Reads ANTHROPIC_API_KEY from the env. Swap for
22
+ // createOpenAIModelCall({ model: '...' }) to use OpenAI instead — see
23
+ // node_modules/loopengine/README.md's "Wiring a real model" section.
24
+ export function createModelCall(): ModelCall {
25
+ return createAnthropicModelCall({ model: 'claude-sonnet-5' })
26
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "dev": "tsx adapters/http.ts",
9
+ "cli": "tsx adapters/cli.ts"
10
+ },
11
+ "dependencies": {
12
+ "loopengine": "^0.0.3"
13
+ },
14
+ "devDependencies": {
15
+ "@types/node": "^22.10.0",
16
+ "tsx": "^4.19.0",
17
+ "typescript": "^5.7.0"
18
+ }
19
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "outDir": "dist"
11
+ },
12
+ "include": ["*.ts", "adapters/*.ts", "agents/*.ts"]
13
+ }