create-langgraph-app 1.2.0 → 1.4.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/README.md CHANGED
@@ -11,7 +11,7 @@ npx create-langgraph-app
11
11
  ## What it does
12
12
 
13
13
  1. Asks for your project name
14
- 2. Lets you pick an LLM provider (OpenAI, Anthropic, Google, Groq, Ollama)
14
+ 2. Lets you pick an LLM provider (OpenAI, Anthropic, Google, Groq, DeepSeek, Ollama)
15
15
  3. Lets you select which agent patterns to include:
16
16
  - **Supervisor** — central coordinator + worker agents
17
17
  - **Swarm** — peer-to-peer agent handoffs
package/dist/create.js CHANGED
@@ -52,6 +52,7 @@ const PROVIDERS = {
52
52
  google: { envKey: "GOOGLE_API_KEY", defaultModel: "gemini-2.0-flash" },
53
53
  groq: { envKey: "GROQ_API_KEY", defaultModel: "llama-3.3-70b-versatile" },
54
54
  ollama: { envKey: "", defaultModel: "llama3.2" },
55
+ deepseek: { envKey: "DEEPSEEK_API_KEY", defaultModel: "deepseek-v4-flash" },
55
56
  };
56
57
  function generateEnv(config) {
57
58
  const prov = PROVIDERS[config.provider];
@@ -67,7 +68,9 @@ function generateEnv(config) {
67
68
  else {
68
69
  lines.push(`# No API key needed for ${config.provider}`);
69
70
  }
70
- lines.push(``, `# Model (optional — defaults to ${prov.defaultModel})`, `# LLM_MODEL=${prov.defaultModel}`, `LLM_TEMPERATURE=0`, ``, `PORT=3000`, ``, `# LangSmith tracing (optional)`, `# LANGCHAIN_TRACING_V2=true`, `# LANGSMITH_API_KEY=`, `# LANGSMITH_PROJECT=${config.name}`);
71
+ lines.push(``, `# Model (optional — defaults to ${prov.defaultModel})`, `# LLM_MODEL=${prov.defaultModel}`, `LLM_TEMPERATURE=0`, ``, ...(config.provider === "deepseek"
72
+ ? [`# DeepSeek reasoning mode (optional — "disabled" | "enabled")`, `# DEEPSEEK_THINKING=disabled`, ``]
73
+ : []), `PORT=3000`, ``, `# LangSmith tracing (optional)`, `# LANGCHAIN_TRACING_V2=true`, `# LANGSMITH_API_KEY=`, `# LANGSMITH_PROJECT=${config.name}`);
71
74
  return lines.join("\n") + "\n";
72
75
  }
73
76
  function generatePackageJson(config) {
@@ -90,6 +93,7 @@ function generatePackageJson(config) {
90
93
  google: "@langchain/google-genai",
91
94
  groq: "@langchain/groq",
92
95
  ollama: "@langchain/ollama",
96
+ deepseek: "@langchain/deepseek",
93
97
  };
94
98
  deps[provPkg[config.provider]] = "latest";
95
99
  const pkg = {
@@ -101,6 +105,7 @@ function generatePackageJson(config) {
101
105
  dev: "tsx src/index.ts",
102
106
  "dev:http": "tsx src/server.ts",
103
107
  test: "vitest run",
108
+ "test:watch": "vitest",
104
109
  typecheck: "tsc --noEmit",
105
110
  },
106
111
  dependencies: Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))),
@@ -126,13 +131,13 @@ function generateTsConfig() {
126
131
  types: ["node"],
127
132
  outDir: "dist",
128
133
  },
129
- include: ["src"],
134
+ include: ["src", "tests"],
130
135
  }, null, 2) + "\n";
131
136
  }
132
137
  function generateEnvConfig(config) {
133
138
  return `import "dotenv/config";
134
139
 
135
- const VALID_PROVIDERS = ["openai", "anthropic", "google", "groq", "ollama"] as const;
140
+ const VALID_PROVIDERS = ["openai", "anthropic", "google", "groq", "ollama", "deepseek"] as const;
136
141
  export type LlmProvider = (typeof VALID_PROVIDERS)[number];
137
142
 
138
143
  function resolveProvider(): LlmProvider {
@@ -149,6 +154,7 @@ const API_KEY_MAP: Record<LlmProvider, string> = {
149
154
  google: "GOOGLE_API_KEY",
150
155
  groq: "GROQ_API_KEY",
151
156
  ollama: "",
157
+ deepseek: "DEEPSEEK_API_KEY",
152
158
  };
153
159
 
154
160
  export const LLM_PROVIDER = resolveProvider();
@@ -161,6 +167,9 @@ if (requiredKey && !process.env[requiredKey]) {
161
167
 
162
168
  export const LLM_MODEL = process.env.LLM_MODEL || undefined;
163
169
  export const LLM_TEMPERATURE = Number(process.env.LLM_TEMPERATURE ?? 0);
170
+ // Uses || rather than ?? so an empty DEEPSEEK_THINKING= in .env falls back
171
+ // to "disabled" instead of sending an invalid thinking type to the API.
172
+ export const DEEPSEEK_THINKING = process.env.DEEPSEEK_THINKING || "disabled";
164
173
  export const PORT = Number(process.env.PORT ?? 3000);
165
174
  `;
166
175
  }
@@ -172,11 +181,15 @@ const PROVIDER_LLM = {
172
181
  google: { pkg: "@langchain/google-genai", className: "ChatGoogleGenerativeAI", modelArg: "model", defaultModel: "gemini-2.0-flash" },
173
182
  groq: { pkg: "@langchain/groq", className: "ChatGroq", modelArg: "model", defaultModel: "llama-3.3-70b-versatile" },
174
183
  ollama: { pkg: "@langchain/ollama", className: "ChatOllama", modelArg: "model", defaultModel: "llama3.2" },
184
+ deepseek: { pkg: "@langchain/deepseek", className: "ChatDeepSeek", modelArg: "model", defaultModel: "deepseek-v4-flash", extraArgs: 'modelKwargs: { thinking: { type: DEEPSEEK_THINKING } }' },
175
185
  };
176
186
  function generateLlmConfig(config) {
177
187
  const p = PROVIDER_LLM[config.provider] ?? PROVIDER_LLM.openai;
188
+ const envImports = config.provider === "deepseek"
189
+ ? "LLM_MODEL, LLM_TEMPERATURE, DEEPSEEK_THINKING"
190
+ : "LLM_MODEL, LLM_TEMPERATURE";
178
191
  return `import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
179
- import { LLM_MODEL, LLM_TEMPERATURE } from "./env";
192
+ import { ${envImports} } from "./env";
180
193
 
181
194
  const DEFAULT_MODEL = "${p.defaultModel}";
182
195
 
@@ -190,7 +203,7 @@ async function createLlm(opts: LlmOptions = {}): Promise<BaseChatModel> {
190
203
  const temperature = opts.temperature ?? LLM_TEMPERATURE;
191
204
 
192
205
  const { ${p.className} } = await import("${p.pkg}");
193
- return new ${p.className}({ ${p.modelArg}: model, temperature });
206
+ return new ${p.className}({ ${p.modelArg}: model, temperature${p.extraArgs ? `, ${p.extraArgs}` : ""} });
194
207
  }
195
208
 
196
209
  let _default: Promise<BaseChatModel> | undefined;
@@ -335,6 +348,252 @@ export function makeSupervisor({
335
348
  }
336
349
  `;
337
350
  }
351
+ // Maps a selected pattern to the app it generates, so the server and demos
352
+ // only reference files that actually exist.
353
+ const PATTERN_APPS = {
354
+ supervisor: { route: "supervisor", fn: "createSupervisorApp", path: "./apps/supervisor" },
355
+ swarm: { route: "swarm", fn: "createSwarmApp", path: "./apps/swarm" },
356
+ hitl: { route: "interrupt", fn: "createInterruptApp", path: "./apps/interrupt" },
357
+ structured: { route: "analyst", fn: "createAnalystApp", path: "./apps/analyst" },
358
+ rag: { route: "rag", fn: "createRagApp", path: "./apps/rag" },
359
+ };
360
+ function selectedApps(config) {
361
+ return config.patterns.map((p) => PATTERN_APPS[p]).filter(Boolean);
362
+ }
363
+ function generateServer(config) {
364
+ const apps = selectedApps(config);
365
+ const imports = apps.map((a) => `import { ${a.fn} } from "${a.path}";`).join("\n");
366
+ const entries = apps.map((a) => ` "${a.route}": asApp(await ${a.fn}()),`).join("\n");
367
+ return `import "./config/env";
368
+ import { fastify, type FastifyError, type FastifyReply, type FastifyRequest } from "fastify";
369
+ import { Command } from "@langchain/langgraph";
370
+ import { AIMessageChunk, type BaseMessage } from "@langchain/core/messages";
371
+ import { PORT } from "./config/env";
372
+ ${imports}
373
+
374
+ // The apps are a mix of agents and graphs whose generic types don't unify;
375
+ // this is the structural slice the routes actually need.
376
+ interface AppGraph {
377
+ invoke(input: unknown, config?: unknown): Promise<{ messages: BaseMessage[] } & Record<string, unknown>>;
378
+ stream(input: unknown, config?: unknown): Promise<AsyncIterable<[unknown, { langgraph_node?: string } | undefined]>>;
379
+ getState(config: unknown): Promise<{ values?: unknown; next?: string[]; tasks?: unknown[] } | undefined>;
380
+ }
381
+ const asApp = (g: unknown) => g as AppGraph;
382
+
383
+ function httpError(status: number, message: string) {
384
+ return Object.assign(new Error(message), { statusCode: status });
385
+ }
386
+
387
+ /** Reject malformed bodies before they reach the agent. */
388
+ function validateMessages(raw: unknown): { role: string; content: string }[] {
389
+ if (raw === undefined) return [];
390
+ if (!Array.isArray(raw)) throw httpError(400, '"messages" must be an array');
391
+ return raw.map((m, i) => {
392
+ const { role, content } = (m ?? {}) as Record<string, unknown>;
393
+ if (typeof role !== "string" || typeof content !== "string") {
394
+ throw httpError(400, \`messages[\${i}] needs string "role" and "content"\`);
395
+ }
396
+ return { role, content };
397
+ });
398
+ }
399
+
400
+ const server = fastify({ logger: false });
401
+
402
+ server.setErrorHandler(async (error: FastifyError, _req: FastifyRequest, reply: FastifyReply) => {
403
+ const status = error.statusCode ?? 500;
404
+ return reply.status(status).send({ error: error.message });
405
+ });
406
+
407
+ async function start() {
408
+ const apps: Record<string, AppGraph> = {
409
+ ${entries}
410
+ };
411
+
412
+ const getApp = (name: string) => {
413
+ const app = apps[name];
414
+ if (!app) throw httpError(404, \`Unknown app: "\${name}". Available: \${Object.keys(apps).join(", ")}\`);
415
+ return app;
416
+ };
417
+
418
+ server.post<{ Params: { app: string } }>("/:app/invoke", async (req, reply) => {
419
+ const app = getApp(req.params.app);
420
+ const body = (req.body ?? {}) as { messages?: unknown; thread_id?: string };
421
+ const messages = validateMessages(body.messages);
422
+ const thread_id = body.thread_id ?? "default";
423
+
424
+ const result = await app.invoke({ messages }, { configurable: { thread_id } });
425
+ const last = result.messages.at(-1);
426
+ return reply.send({
427
+ messages: result.messages,
428
+ structuredResponse: (result as Record<string, unknown>).structuredResponse ?? null,
429
+ lastMessage: typeof last?.content === "string" ? last.content : JSON.stringify(last?.content),
430
+ });
431
+ });
432
+
433
+ server.post<{ Params: { app: string } }>("/:app/stream", async (req, reply) => {
434
+ const app = getApp(req.params.app);
435
+ const body = (req.body ?? {}) as { messages?: unknown; thread_id?: string };
436
+ const messages = validateMessages(body.messages);
437
+ const thread_id = body.thread_id ?? "default";
438
+
439
+ reply.raw.writeHead(200, {
440
+ "Content-Type": "text/event-stream",
441
+ "Cache-Control": "no-cache",
442
+ Connection: "keep-alive",
443
+ });
444
+ const send = (e: unknown) => reply.raw.write(\`data: \${JSON.stringify(e)}\\n\\n\`);
445
+
446
+ try {
447
+ const stream = await app.stream({ messages }, { configurable: { thread_id }, streamMode: "messages" });
448
+ for await (const [chunk, metadata] of stream) {
449
+ if (chunk instanceof AIMessageChunk) {
450
+ send({ type: "token", content: chunk.content, node: metadata?.langgraph_node ?? "unknown" });
451
+ }
452
+ }
453
+ send({ type: "done" });
454
+ } catch (err) {
455
+ send({ type: "error", content: err instanceof Error ? err.message : "Stream failed" });
456
+ } finally {
457
+ reply.raw.end();
458
+ }
459
+ });
460
+
461
+ server.post<{ Params: { app: string } }>("/:app/resume", async (req, reply) => {
462
+ const app = getApp(req.params.app);
463
+ const body = (req.body ?? {}) as { thread_id?: string; decision?: string };
464
+ if (body.decision === undefined) {
465
+ return reply.status(400).send({ error: '"decision" field is required' });
466
+ }
467
+ const result = await app.invoke(new Command({ resume: body.decision }), {
468
+ configurable: { thread_id: body.thread_id ?? "default" },
469
+ });
470
+ const last = result.messages.at(-1);
471
+ return reply.send({
472
+ messages: result.messages,
473
+ lastMessage: typeof last?.content === "string" ? last.content : JSON.stringify(last?.content),
474
+ });
475
+ });
476
+
477
+ server.get<{ Params: { app: string; threadId: string } }>("/:app/threads/:threadId", async (req, reply) => {
478
+ const app = getApp(req.params.app);
479
+ const state = await app.getState({ configurable: { thread_id: req.params.threadId } });
480
+ if (!state?.values) return reply.status(404).send({ error: "Thread not found" });
481
+ return reply.send({ values: state.values, next: state.next ?? [], tasks: state.tasks ?? [] });
482
+ });
483
+
484
+ server.get("/health", async () => ({ status: "ok", apps: Object.keys(apps) }));
485
+
486
+ // Container runtimes send SIGTERM on stop — drain instead of dying mid-request.
487
+ let shuttingDown = false;
488
+ for (const signal of ["SIGTERM", "SIGINT"] as const) {
489
+ process.on(signal, () => {
490
+ if (shuttingDown) return;
491
+ shuttingDown = true;
492
+ server.close().then(() => process.exit(0)).catch(() => process.exit(1));
493
+ });
494
+ }
495
+
496
+ await server.listen({ port: PORT, host: "0.0.0.0" });
497
+ console.log(\`Server running at http://localhost:\${PORT}\`);
498
+ console.log(\`Apps: \${Object.keys(apps).join(", ")}\`);
499
+ }
500
+
501
+ start().catch((err) => {
502
+ console.error("Failed to start server:", err);
503
+ process.exit(1);
504
+ });
505
+ `;
506
+ }
507
+ function generateScriptedModel() {
508
+ return `import { BaseChatModel } from "@langchain/core/language_models/chat_models";
509
+ import { AIMessage, type BaseMessage } from "@langchain/core/messages";
510
+ import type { ChatResult } from "@langchain/core/outputs";
511
+
512
+ /**
513
+ * A model that replays a queued list of responses, so tests can drive real
514
+ * graphs offline — no API key, no network, fully deterministic.
515
+ */
516
+ export class ScriptedToolCallingModel extends BaseChatModel {
517
+ private queue: AIMessage[];
518
+
519
+ constructor(queue: AIMessage[]) {
520
+ super({});
521
+ this.queue = [...queue];
522
+ }
523
+
524
+ _llmType(): string {
525
+ return "scripted-tool-calling";
526
+ }
527
+
528
+ override bindTools(): this {
529
+ return this;
530
+ }
531
+
532
+ async _generate(_messages: BaseMessage[]): Promise<ChatResult> {
533
+ const message = this.queue.shift();
534
+ if (!message) throw new Error("Scripted model ran out of responses");
535
+ return { generations: [{ message, text: "" }] };
536
+ }
537
+ }
538
+ `;
539
+ }
540
+ function generateAgentTest(config) {
541
+ const hasSupervisor = config.patterns.includes("supervisor");
542
+ const supervisorTest = hasSupervisor
543
+ ? `
544
+ it("delegates to a subagent and returns its answer", async () => {
545
+ const llm = new ScriptedToolCallingModel([
546
+ // supervisor delegates
547
+ new AIMessage({
548
+ content: "",
549
+ tool_calls: [{ id: "c1", name: "worker", args: { task: "do the thing" } }],
550
+ }),
551
+ // subagent answers
552
+ new AIMessage("worker done"),
553
+ // supervisor wraps up
554
+ new AIMessage("All finished."),
555
+ ]);
556
+
557
+ const worker = makeAgent({ name: "worker", llm, tools: [] });
558
+ const app = await makeSupervisor({
559
+ subagents: [{ name: "worker", description: "Does the thing.", agent: worker }],
560
+ llm,
561
+ checkpointer: new MemorySaver(),
562
+ });
563
+
564
+ const result = await app.invoke(
565
+ { messages: [{ role: "user", content: "do the thing" }] },
566
+ { configurable: { thread_id: "t1" } }
567
+ );
568
+
569
+ expect(result.messages.at(-1)?.content).toBe("All finished.");
570
+ // the supervisor sees the subagent's final answer, not its internals
571
+ const toolMessages = result.messages.filter((m) => m.getType() === "tool");
572
+ expect(toolMessages).toHaveLength(1);
573
+ expect(toolMessages[0].content).toBe("worker done");
574
+ });
575
+ `
576
+ : "";
577
+ const supervisorImports = hasSupervisor
578
+ ? `import { makeSupervisor } from "../src/agents/supervisor";\nimport { MemorySaver } from "@langchain/langgraph";\n`
579
+ : "";
580
+ return `import { describe, expect, it } from "vitest";
581
+ import { AIMessage } from "@langchain/core/messages";
582
+ import { makeAgent } from "../src/agents/factory";
583
+ ${supervisorImports}import { ScriptedToolCallingModel } from "./helpers/scripted-model";
584
+
585
+ describe("agents", () => {
586
+ it("runs a single agent to a final answer", async () => {
587
+ const llm = new ScriptedToolCallingModel([new AIMessage("Hello there.")]);
588
+ const agent = makeAgent({ name: "assistant", llm, tools: [], system: "Be brief." });
589
+
590
+ const result = await agent.invoke({ messages: [{ role: "user", content: "hi" }] });
591
+
592
+ expect(result.messages.at(-1)?.content).toBe("Hello there.");
593
+ });
594
+ ${supervisorTest}});
595
+ `;
596
+ }
338
597
  // Pattern-specific: only files for selected patterns are generated
339
598
  function getPatternFiles(config) {
340
599
  const files = [];
@@ -706,6 +965,11 @@ export async function createRagApp() {
706
965
  );
707
966
  console.log("Result:", rag.messages.at(-1)?.content);`);
708
967
  }
968
+ // HTTP server — routes only the apps that were generated
969
+ files.push({ path: "src/server.ts", content: generateServer(config) });
970
+ // Tests — offline, no API key needed
971
+ files.push({ path: "tests/helpers/scripted-model.ts", content: generateScriptedModel() });
972
+ files.push({ path: "tests/agents.test.ts", content: generateAgentTest(config) });
709
973
  // Generate index.ts
710
974
  files.push({
711
975
  path: "src/index.ts",
@@ -732,6 +996,7 @@ async function main() {
732
996
  { value: "anthropic", label: "Anthropic (Claude Sonnet)" },
733
997
  { value: "google", label: "Google (Gemini 2.0 Flash)" },
734
998
  { value: "groq", label: "Groq (Llama 3.3 70B)" },
999
+ { value: "deepseek", label: "DeepSeek (deepseek-v4-flash)" },
735
1000
  { value: "ollama", label: "Ollama (local, no API key)" },
736
1001
  ]);
737
1002
  const patterns = await choose(rl, "Which patterns? (select multiple)", [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-langgraph-app",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Scaffold a new LangGraph multi-agent project in seconds",
5
5
  "type": "module",
6
6
  "bin": {