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.
Files changed (3) hide show
  1. package/README.md +50 -12
  2. package/dist/create.js +71 -18
  3. 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 (OpenAI, Anthropic, Google, Groq, DeepSeek, Ollama)
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 + worker agents
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 with only the patterns you selected
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 # Run locally
59
+ npm run dev # run the CLI locally
60
+ npm run build # typecheck and emit dist/
30
61
  ```
31
62
 
32
- ## Publishing
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
- cd create-langgraph-app
36
- npm run build
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<{ values?: unknown; next?: string[]; tasks?: unknown[] } | undefined>;
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?: string };
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 ?? "default";
558
+ const thread_id = validateThreadId(body.thread_id);
524
559
 
525
- const result = await app.invoke({ messages }, { configurable: { thread_id } });
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?: string };
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 ?? "default";
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 send = (e: unknown) => reply.raw.write(\`data: \${JSON.stringify(e)}\\n\\n\`);
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?: string; decision?: string };
565
- if (body.decision === undefined) {
566
- return reply.status(400).send({ error: '"decision" field is required' });
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
- const result = await app.invoke(new Command({ resume: body.decision }), {
569
- configurable: { thread_id: body.thread_id ?? "default" },
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 app = getApp(req.params.app);
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-langgraph-app",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Scaffold a new LangGraph multi-agent project in seconds",
5
5
  "type": "module",
6
6
  "bin": {