create-langgraph-app 1.5.0 → 1.5.2
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 +50 -12
- package/dist/create.js +71 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Interactive CLI to scaffold a new LangGraph multi-agent project in seconds.
|
|
4
4
|
|
|
5
|
-
## Usage
|
|
6
|
-
|
|
7
5
|
```bash
|
|
8
6
|
npx create-langgraph-app
|
|
9
7
|
```
|
|
@@ -11,28 +9,68 @@ npx create-langgraph-app
|
|
|
11
9
|
## What it does
|
|
12
10
|
|
|
13
11
|
1. Asks for your project name
|
|
14
|
-
2. Lets you pick an LLM provider
|
|
12
|
+
2. Lets you pick an LLM provider — OpenAI, Anthropic, Google, Groq, DeepSeek, or Ollama
|
|
15
13
|
3. Lets you select which agent patterns to include:
|
|
16
|
-
- **Supervisor** — central coordinator
|
|
14
|
+
- **Supervisor** — central coordinator delegating to worker agents
|
|
17
15
|
- **Swarm** — peer-to-peer agent handoffs
|
|
18
16
|
- **Human-in-the-Loop** — approval before dangerous actions
|
|
19
|
-
- **Structured Output** — typed JSON responses
|
|
20
|
-
- **RAG** — retrieval-augmented generation
|
|
21
|
-
4. Generates a ready-to-run project
|
|
17
|
+
- **Structured Output** — typed JSON responses validated by Zod
|
|
18
|
+
- **RAG** — retrieval-augmented generation over an in-memory vector store
|
|
19
|
+
4. Generates a ready-to-run project containing only the patterns you picked
|
|
22
20
|
5. Installs dependencies
|
|
23
21
|
|
|
22
|
+
## What you get
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
src/
|
|
26
|
+
├── config/ env validation, LLM factory, embeddings (with RAG)
|
|
27
|
+
├── agents/ makeAgent() and the helpers your patterns need
|
|
28
|
+
├── apps/ one file per selected pattern
|
|
29
|
+
├── tools/ retrieval tool + vector store (with RAG)
|
|
30
|
+
├── server.ts Fastify server — invoke, stream, resume, threads
|
|
31
|
+
└── index.ts CLI demo running each pattern
|
|
32
|
+
tests/ offline tests that need no API key
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Everything is built on LangChain's `createAgent` — the **subagents** pattern for
|
|
36
|
+
supervisors, **handoffs** for swarms. No deprecated packages.
|
|
37
|
+
|
|
38
|
+
## RAG and embeddings
|
|
39
|
+
|
|
40
|
+
Selecting RAG generates a working implementation: chunking, embedding,
|
|
41
|
+
cosine-similarity search, and a retrieval tool the agent calls before answering.
|
|
42
|
+
Swap `SAMPLE_DOCS` in `src/tools/rag.ts` for your own documents.
|
|
43
|
+
|
|
44
|
+
Embeddings are a separate capability from chat. OpenAI, Google and Ollama have
|
|
45
|
+
their own; **Anthropic, Groq and DeepSeek do not**, so those projects use OpenAI
|
|
46
|
+
embeddings and need `OPENAI_API_KEY` in addition to the chat provider's key. The
|
|
47
|
+
generated `.env` says so, and `EMBEDDINGS_MODEL` overrides the model.
|
|
48
|
+
|
|
49
|
+
To run RAG with no API key at all, scaffold with Ollama.
|
|
50
|
+
|
|
51
|
+
If embeddings are unavailable, only RAG is affected — the other patterns and
|
|
52
|
+
routes keep working, and the server logs why `/rag` is disabled.
|
|
53
|
+
|
|
24
54
|
## Development
|
|
25
55
|
|
|
26
56
|
```bash
|
|
27
57
|
cd create-langgraph-app
|
|
28
58
|
npm install
|
|
29
|
-
npm run dev
|
|
59
|
+
npm run dev # run the CLI locally
|
|
60
|
+
npm run build # typecheck and emit dist/
|
|
30
61
|
```
|
|
31
62
|
|
|
32
|
-
|
|
63
|
+
Generated projects are compiled in CI, so a change that breaks the output fails
|
|
64
|
+
the build. See `tests/scaffolder/` in the [starter kit](https://github.com/ac12644/langgraph-starter-kit).
|
|
65
|
+
|
|
66
|
+
## Releasing
|
|
67
|
+
|
|
68
|
+
Publishing is automated. Bump the version in `package.json`, then push a
|
|
69
|
+
matching `cli-v*` tag:
|
|
33
70
|
|
|
34
71
|
```bash
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
npm publish
|
|
72
|
+
git tag -a cli-v1.5.0 -m "create-langgraph-app v1.5.0"
|
|
73
|
+
git push origin cli-v1.5.0
|
|
38
74
|
```
|
|
75
|
+
|
|
76
|
+
The `Publish CLI to npm` workflow builds and publishes with provenance.
|
package/dist/create.js
CHANGED
|
@@ -477,27 +477,54 @@ ${imports}
|
|
|
477
477
|
interface AppGraph {
|
|
478
478
|
invoke(input: unknown, config?: unknown): Promise<{ messages: BaseMessage[] } & Record<string, unknown>>;
|
|
479
479
|
stream(input: unknown, config?: unknown): Promise<AsyncIterable<[unknown, { langgraph_node?: string } | undefined]>>;
|
|
480
|
-
getState(config: unknown): Promise<
|
|
480
|
+
getState(config: unknown): Promise<ThreadState | undefined>;
|
|
481
|
+
}
|
|
482
|
+
interface ThreadState {
|
|
483
|
+
values?: unknown;
|
|
484
|
+
next?: string[];
|
|
485
|
+
tasks?: { interrupts?: { value?: unknown }[] }[];
|
|
486
|
+
createdAt?: string | null;
|
|
481
487
|
}
|
|
482
488
|
const asApp = (g: unknown) => g as AppGraph;
|
|
483
489
|
|
|
490
|
+
/** What each pending interrupt() was given. Empty when the thread isn't paused. */
|
|
491
|
+
function pendingInterrupts(state: ThreadState | undefined): unknown[] {
|
|
492
|
+
return (state?.tasks ?? []).flatMap((t) => t.interrupts ?? []).map((i) => i.value);
|
|
493
|
+
}
|
|
494
|
+
|
|
484
495
|
function httpError(status: number, message: string) {
|
|
485
496
|
return Object.assign(new Error(message), { statusCode: status });
|
|
486
497
|
}
|
|
487
498
|
|
|
499
|
+
// \`system\` is excluded: the app owns its system prompt, and accepting one
|
|
500
|
+
// from the request lets any caller override it.
|
|
501
|
+
const CLIENT_ROLES = ["user", "assistant"];
|
|
502
|
+
|
|
488
503
|
/** Reject malformed bodies before they reach the agent. */
|
|
489
504
|
function validateMessages(raw: unknown): { role: string; content: string }[] {
|
|
490
|
-
if (raw === undefined) return [];
|
|
491
505
|
if (!Array.isArray(raw)) throw httpError(400, '"messages" must be an array');
|
|
506
|
+
if (raw.length === 0) throw httpError(400, '"messages" must not be empty');
|
|
492
507
|
return raw.map((m, i) => {
|
|
493
508
|
const { role, content } = (m ?? {}) as Record<string, unknown>;
|
|
494
509
|
if (typeof role !== "string" || typeof content !== "string") {
|
|
495
510
|
throw httpError(400, \`messages[\${i}] needs string "role" and "content"\`);
|
|
496
511
|
}
|
|
512
|
+
if (!CLIENT_ROLES.includes(role)) {
|
|
513
|
+
throw httpError(400, \`messages[\${i}].role must be one of: \${CLIENT_ROLES.join(", ")}\`);
|
|
514
|
+
}
|
|
497
515
|
return { role, content };
|
|
498
516
|
});
|
|
499
517
|
}
|
|
500
518
|
|
|
519
|
+
/** Defaults to "default"; anything but a non-empty string is a 400, not a 500. */
|
|
520
|
+
function validateThreadId(raw: unknown): string {
|
|
521
|
+
if (raw === undefined) return "default";
|
|
522
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
523
|
+
throw httpError(400, '"thread_id" must be a non-empty string');
|
|
524
|
+
}
|
|
525
|
+
return raw;
|
|
526
|
+
}
|
|
527
|
+
|
|
501
528
|
const server = fastify({ logger: false });
|
|
502
529
|
|
|
503
530
|
server.setErrorHandler(async (error: FastifyError, _req: FastifyRequest, reply: FastifyReply) => {
|
|
@@ -516,43 +543,64 @@ ${entries}${ragEntry}
|
|
|
516
543
|
return app;
|
|
517
544
|
};
|
|
518
545
|
|
|
546
|
+
// A thread that never ran still has a snapshot (values: {}); a checkpoint
|
|
547
|
+
// (createdAt) is what says it exists.
|
|
548
|
+
const getThread = async (app: AppGraph, thread_id: string) => {
|
|
549
|
+
const state = await app.getState({ configurable: { thread_id } });
|
|
550
|
+
if (!state?.createdAt) throw httpError(404, \`Thread "\${thread_id}" not found\`);
|
|
551
|
+
return state;
|
|
552
|
+
};
|
|
553
|
+
|
|
519
554
|
server.post<{ Params: { app: string } }>("/:app/invoke", async (req, reply) => {
|
|
520
555
|
const app = getApp(req.params.app);
|
|
521
|
-
const body = (req.body ?? {}) as { messages?: unknown; thread_id?:
|
|
556
|
+
const body = (req.body ?? {}) as { messages?: unknown; thread_id?: unknown };
|
|
522
557
|
const messages = validateMessages(body.messages);
|
|
523
|
-
const thread_id = body.thread_id
|
|
558
|
+
const thread_id = validateThreadId(body.thread_id);
|
|
524
559
|
|
|
525
|
-
const
|
|
560
|
+
const config = { configurable: { thread_id } };
|
|
561
|
+
const result = await app.invoke({ messages }, config);
|
|
526
562
|
const last = result.messages.at(-1);
|
|
527
563
|
return reply.send({
|
|
528
564
|
messages: result.messages,
|
|
529
565
|
structuredResponse: (result as Record<string, unknown>).structuredResponse ?? null,
|
|
530
566
|
lastMessage: typeof last?.content === "string" ? last.content : JSON.stringify(last?.content),
|
|
567
|
+
// Non-empty = paused for approval; answer via POST /:app/resume.
|
|
568
|
+
interrupts: pendingInterrupts(await app.getState(config)),
|
|
531
569
|
});
|
|
532
570
|
});
|
|
533
571
|
|
|
534
572
|
server.post<{ Params: { app: string } }>("/:app/stream", async (req, reply) => {
|
|
535
573
|
const app = getApp(req.params.app);
|
|
536
|
-
const body = (req.body ?? {}) as { messages?: unknown; thread_id?:
|
|
574
|
+
const body = (req.body ?? {}) as { messages?: unknown; thread_id?: unknown };
|
|
537
575
|
const messages = validateMessages(body.messages);
|
|
538
|
-
const thread_id = body.thread_id
|
|
576
|
+
const thread_id = validateThreadId(body.thread_id);
|
|
539
577
|
|
|
540
578
|
reply.raw.writeHead(200, {
|
|
541
579
|
"Content-Type": "text/event-stream",
|
|
542
580
|
"Cache-Control": "no-cache",
|
|
543
581
|
Connection: "keep-alive",
|
|
544
582
|
});
|
|
545
|
-
const
|
|
583
|
+
const controller = new AbortController();
|
|
584
|
+
reply.raw.on("close", () => {
|
|
585
|
+
if (!reply.raw.writableFinished) controller.abort();
|
|
586
|
+
});
|
|
587
|
+
const send = (e: unknown) => {
|
|
588
|
+
if (controller.signal.aborted) return;
|
|
589
|
+
reply.raw.write(\`data: \${JSON.stringify(e)}\\n\\n\`);
|
|
590
|
+
};
|
|
546
591
|
|
|
547
592
|
try {
|
|
548
|
-
const stream = await app.stream({ messages }, { configurable: { thread_id }, streamMode: "messages" });
|
|
593
|
+
const stream = await app.stream({ messages }, { configurable: { thread_id }, streamMode: "messages", signal: controller.signal });
|
|
549
594
|
for await (const [chunk, metadata] of stream) {
|
|
550
595
|
if (chunk instanceof AIMessageChunk) {
|
|
551
596
|
send({ type: "token", content: chunk.content, node: metadata?.langgraph_node ?? "unknown" });
|
|
552
597
|
}
|
|
553
598
|
}
|
|
599
|
+
const interrupts = pendingInterrupts(await app.getState({ configurable: { thread_id } }));
|
|
600
|
+
if (interrupts.length > 0) send({ type: "interrupt", content: interrupts });
|
|
554
601
|
send({ type: "done" });
|
|
555
602
|
} catch (err) {
|
|
603
|
+
if (controller.signal.aborted) return;
|
|
556
604
|
send({ type: "error", content: err instanceof Error ? err.message : "Stream failed" });
|
|
557
605
|
} finally {
|
|
558
606
|
reply.raw.end();
|
|
@@ -561,24 +609,29 @@ ${entries}${ragEntry}
|
|
|
561
609
|
|
|
562
610
|
server.post<{ Params: { app: string } }>("/:app/resume", async (req, reply) => {
|
|
563
611
|
const app = getApp(req.params.app);
|
|
564
|
-
const body = (req.body ?? {}) as { thread_id?:
|
|
565
|
-
|
|
566
|
-
|
|
612
|
+
const body = (req.body ?? {}) as { thread_id?: unknown; decision?: unknown };
|
|
613
|
+
const thread_id = validateThreadId(body.thread_id);
|
|
614
|
+
if (typeof body.decision !== "string") throw httpError(400, '"decision" must be a string');
|
|
615
|
+
|
|
616
|
+
// Resuming a thread that isn't paused is a silent no-op — refuse it so an
|
|
617
|
+
// approval UI can't report "approved" for something that never waited.
|
|
618
|
+
if (pendingInterrupts(await getThread(app, thread_id)).length === 0) {
|
|
619
|
+
throw httpError(409, \`Thread "\${thread_id}" is not waiting for a decision\`);
|
|
567
620
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
});
|
|
621
|
+
|
|
622
|
+
const config = { configurable: { thread_id } };
|
|
623
|
+
const result = await app.invoke(new Command({ resume: body.decision }), config);
|
|
571
624
|
const last = result.messages.at(-1);
|
|
572
625
|
return reply.send({
|
|
573
626
|
messages: result.messages,
|
|
574
627
|
lastMessage: typeof last?.content === "string" ? last.content : JSON.stringify(last?.content),
|
|
628
|
+
// A resumed run can pause again (e.g. a second approval).
|
|
629
|
+
interrupts: pendingInterrupts(await app.getState(config)),
|
|
575
630
|
});
|
|
576
631
|
});
|
|
577
632
|
|
|
578
633
|
server.get<{ Params: { app: string; threadId: string } }>("/:app/threads/:threadId", async (req, reply) => {
|
|
579
|
-
const
|
|
580
|
-
const state = await app.getState({ configurable: { thread_id: req.params.threadId } });
|
|
581
|
-
if (!state?.values) return reply.status(404).send({ error: "Thread not found" });
|
|
634
|
+
const state = await getThread(getApp(req.params.app), req.params.threadId);
|
|
582
635
|
return reply.send({ values: state.values, next: state.next ?? [], tasks: state.tasks ?? [] });
|
|
583
636
|
});
|
|
584
637
|
|