Build Your Own Simple Agent App from Scratch
The previous two chapters settled "who is responsible" and "how it runs"; this chapter builds it by hand. You are still that helpdesk: the same "is the demo service online?", taken from a single node all the way to tools, models, and observation, then placed inside NestJS. This page builds a "demo service status Q&A" App. First get it running with one node, then let it read tool results, hand them to the model to compose the answer, and receive running status. Every stage uses the same defineWorkflow → new AgentCoreEngine → agent.run style.
When you need to generate a standalone project directly, follow Create an App to use the SDK's Node/TypeScript initializer. It provides a program entry, real tool authorization, empty materials, and offline cases; this page continues to explain how the execution stages combine.
The tutorial's default version is fully offline: no API Key, no service access, and no database. The offline Provider only generates strings from fixed data, for understanding the execution mechanism; it does not represent a real model's semantic capability.
When you need to debug model service integration, use the SDK's MockGPT Kit directly: generate directly, inject into a Provider, or start a minimal Node service, reusing the same set of mock rules. No separate simulator installation is needed.
1. Prepare the environment and the SDK
Use Node.js 22.12 or later. Create an app and install the dependency from npm:
npm init -y
npm pkg set type=module
npm install @jangts/agent-coreDependencies are installed by the package manager according to the package declarations. When using a locally distributed package or an offline package source, see Installation and Integration.
Save JavaScript examples as .mjs and run them directly. Save TypeScript examples as .ts and use your project's own compilation flow; in a standalone directory you can install the compiler and run:
npm install --save-dev typescript @types/node
npx tsc --module nodenext --target es2022 --strict --outDir dist app.ts
node dist/app.jsThe generics and type imports in the code below only affect TypeScript. The JS versions have the same call and return contracts.
2. Get one node running first
Do not aim for a clever answer yet; first get the thinnest possible chain running. Save the following as app.ts or app.mjs. For now the answer is fixed, so you can see clearly how the Workflow, state updates, and final delivery relate.
import { AgentCoreEngine, defineWorkflow, route } from '@jangts/agent-core';
type State = { question: string; answer?: string };
const workflow = defineWorkflow<State, string>({
id: 'service-helpdesk',
nodes: [{
id: 'answer',
description: '回答演示服务状态',
run: () => ({ answer: '演示服务在线。' }),
routes: [route.deliver('answer')],
}],
});
const agent = new AgentCoreEngine({ workflow });
const answer = await agent.run({ question: '演示服务在线吗?' });
console.log(answer);import { AgentCoreEngine, defineWorkflow, route } from '@jangts/agent-core';
const workflow = defineWorkflow({
id: 'service-helpdesk',
nodes: [{
id: 'answer',
description: '回答演示服务状态',
run: () => ({ answer: '演示服务在线。' }),
routes: [route.deliver('answer')],
}],
});
const agent = new AgentCoreEngine({ workflow });
const answer = await agent.run({ question: '演示服务在线吗?' });
console.log(answer);Run node app.mjs. Expected output:
演示服务在线。The final result here is just a string. question is the initial state; the node returns only the answer it adds; the delivery route reads that field. When no entry node is configured manually, the first node is the entry.
3. Add a model binding without changing how it runs
It can answer now, but the answer is hard-coded text. To run the output through a model interface without overturning the previous step, bind a model: let the same answer node generate text through the model interface. The offline Provider receives ordinary model messages and returns text as a stream.
Replace the file from the previous step with the version below. You can compare and see: what is new is the Provider and the model binding at construction, and the node now has one model.invoke inside; agent.run({ question }) and the way you get the final result are unchanged.
import { AgentCoreEngine, defineWorkflow, route } from '@jangts/agent-core';
import type { ChatProvider } from '@jangts/agent-core/types';
type State = { question: string; answer?: string };
const provider: ChatProvider = {
async *stream(messages, context) {
context.signal.throwIfAborted();
yield `已收到问题:${messages.at(-1)?.content ?? ''}`;
},
};
const workflow = defineWorkflow<State, string>({
id: 'service-helpdesk',
nodes: [{
id: 'answer',
description: '通过模型接口回答',
async run(state, { model }) {
const { value } = await model.invoke({ input: state.question });
return { answer: value };
},
routes: [route.deliver('answer')],
}],
});
const agent = new AgentCoreEngine({ workflow, model: { provider, maxCalls: 1 } });
console.log(await agent.run({ question: '演示服务在线吗?' }));import { AgentCoreEngine, defineWorkflow, route } from '@jangts/agent-core';
const provider = {
async *stream(messages, context) {
context.signal.throwIfAborted();
yield `已收到问题:${messages.at(-1)?.content ?? ''}`;
},
};
const workflow = defineWorkflow({
id: 'service-helpdesk',
nodes: [{
id: 'answer',
description: '通过模型接口回答',
async run(state, { model }) {
const { value } = await model.invoke({ input: state.question });
return { answer: value };
},
routes: [route.deliver('answer')],
}],
});
const agent = new AgentCoreEngine({ workflow, model: { provider, maxCalls: 1 } });
console.log(await agent.run({ question: '演示服务在线吗?' }));model.invoke returns { value, invocation }, and value is written back into state here. The model invocation receipt and the App's final Result belong to different contracts: the current Workflow still delivers a string in the end.
4. Base the answer on a tool that really runs
It can answer now, but what it answers is text repeated by the model, not a status that was really fetched. To make it state the status it actually fetched, the next step is to turn the query into a tool and let the answer rest on a real receipt—here "real" means it actually went through tool execution and parsing, not treating the model's "already queried" as a receipt. The tool uses offline constants and does not access the production service.
To reuse it later from the CLI and NestJS, move the fixed definitions into service-agent.ts/service-agent.mjs. createServiceAgent is this example App's own assembly function, not a new SDK entry point.
import { AgentCoreEngine, Support, defineWorkflow, route } from '@jangts/agent-core';
import type { ChatProvider, ExecutionOptions } from '@jangts/agent-core/types';
type Status = { status: 'online'; latencyMs: number };
type State = { question: string; service?: Status; answer?: string };
// 固定定义只写一次;这个离线公开资料工具不需要用户身份。
const tools = Support.defineToolCatalog([{
id: 'get_status',
kind: 'read',
description: '读取演示服务的当前状态',
inputSchema: {
jsonSchema: { type: 'object', properties: {}, additionalProperties: false },
parse(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).length !== 0) throw new TypeError('状态查询不接受参数');
return {};
},
},
authorize() {
// 仅适用于本例的公开离线数据;真实用户数据必须在这里校验授权。
},
execute: (): Status => ({ status: 'online', latencyMs: 12 }),
parseOutput(value: unknown): Status {
if (!value || typeof value !== 'object') throw new TypeError('状态结果无效');
const status = value as Partial<Status>;
if (status.status !== 'online' || typeof status.latencyMs !== 'number'
|| !Number.isFinite(status.latencyMs) || status.latencyMs < 0) {
throw new TypeError('状态结果无效');
}
return { status: status.status, latencyMs: status.latencyMs };
},
}]);
const provider: ChatProvider = {
async *stream(messages, context) {
context.signal.throwIfAborted();
// 固定模拟协议,仅用于离线教程;不是语义理解或事实校验器。
const input = JSON.parse(messages.at(-1)?.content ?? '{}');
yield `演示服务${input.service.status === 'online' ? '在线' : '不可用'},延迟 ${input.service.latencyMs}ms。`;
},
};
const workflow = defineWorkflow<State, string>({
id: 'service-helpdesk',
nodes: [
{
id: 'lookup',
description: '执行状态查询工具',
provides: ['service'],
async run(_state, { tools, report }) {
report({ payload: { phase: 'lookup', message: '正在查询服务状态' } });
const { value } = await tools.invoke({ toolId: 'get_status', input: {} });
return { service: value as Status };
},
routes: [route.to('answer')],
},
{
id: 'answer',
description: '根据查询回执组织回答',
requires: ['service'],
provides: ['answer'],
async run(state, { model }) {
const { value } = await model.invoke({
input: JSON.stringify({ question: state.question, service: state.service }),
});
return { answer: value };
},
routes: [route.deliver('answer')],
},
],
});
export function createServiceAgent(execution: ExecutionOptions = {}) {
const runtime = tools.bind({ app: {} });
return new AgentCoreEngine({
workflow,
model: { provider, maxCalls: 1 },
maxRounds: 1,
execution,
}).bindToolRuntime(runtime);
}import { AgentCoreEngine, Support, defineWorkflow, route } from '@jangts/agent-core';
const tools = Support.defineToolCatalog([{
id: 'get_status',
kind: 'read',
description: '读取演示服务的当前状态',
inputSchema: {
jsonSchema: { type: 'object', properties: {}, additionalProperties: false },
parse(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).length !== 0) throw new TypeError('状态查询不接受参数');
return {};
},
},
authorize() {
// 仅适用于本例的公开离线数据;真实用户数据必须在这里校验授权。
},
execute: () => ({ status: 'online', latencyMs: 12 }),
parseOutput(value) {
if (!value || typeof value !== 'object' || value.status !== 'online'
|| typeof value.latencyMs !== 'number' || !Number.isFinite(value.latencyMs)
|| value.latencyMs < 0) throw new TypeError('状态结果无效');
return { status: value.status, latencyMs: value.latencyMs };
},
}]);
const provider = {
async *stream(messages, context) {
context.signal.throwIfAborted();
const input = JSON.parse(messages.at(-1)?.content ?? '{}');
yield `演示服务${input.service.status === 'online' ? '在线' : '不可用'},延迟 ${input.service.latencyMs}ms。`;
},
};
const workflow = defineWorkflow({
id: 'service-helpdesk',
nodes: [
{
id: 'lookup',
description: '执行状态查询工具',
provides: ['service'],
async run(_state, { tools, report }) {
report({ payload: { phase: 'lookup', message: '正在查询服务状态' } });
const { value } = await tools.invoke({ toolId: 'get_status', input: {} });
return { service: value };
},
routes: [route.to('answer')],
},
{
id: 'answer',
description: '根据查询回执组织回答',
requires: ['service'],
provides: ['answer'],
async run(state, { model }) {
const { value } = await model.invoke({
input: JSON.stringify({ question: state.question, service: state.service }),
});
return { answer: value };
},
routes: [route.deliver('answer')],
},
],
});
export function createServiceAgent(execution = {}) {
const runtime = tools.bind({ app: {} });
return new AgentCoreEngine({
workflow,
model: { provider, maxCalls: 1 },
maxRounds: 1,
execution,
}).bindToolRuntime(runtime);
}This example accepts the status only after tool output parsing. as Status in TypeScript only associates the unknown returned by the low-level tool call with the result this example has already validated; it performs no runtime validation—the real validation is in parseOutput.
This does not let the model decide whether to call the tool: the Workflow explicitly states lookup first, then answer. To add the ability for the model to choose, keep registering model capabilities and transports, then have existing nodes write the structured selection into state; real tool execution and authorization still follow the same contract.
5. Plain Node.js: add observation without changing the Q&A entry
The answer now has a real basis, but your colleague in the group chat cannot see the process. Next, add observation to expose "which step the lookup has reached", without touching the Q&A entry.
Create the CLI entry. Both languages import the assembly function from the previous section:
import { createServiceAgent } from './service-agent.js';
const agent = createServiceAgent({
observe: {
accepts: ({ event, content }) => content === 'metadata' || event === 'node.progress',
event(fact) {
if (fact.type === 'node.progress') console.log('[进度]', fact.payload.payload);
if (fact.type === 'model.start') console.log('[进度] 正在组织回答');
},
},
});
const answer = await agent.run({ question: '演示服务在线吗?' });
console.log(answer);import { createServiceAgent } from './service-agent.mjs';
const agent = createServiceAgent({
observe: {
accepts: ({ event, content }) => content === 'metadata' || event === 'node.progress',
event(fact) {
if (fact.type === 'node.progress') console.log('[进度]', fact.payload.payload);
if (fact.type === 'model.start') console.log('[进度] 正在组织回答');
},
},
});
const answer = await agent.run({ question: '演示服务在线吗?' });
console.log(answer);You should first see node progress, then the model start, and finally:
演示服务在线,延迟 12ms。The process can be observed without a configured persistent log. Nodes only report content; execution identity is provided by Core. Here, node.progress content is explicitly accepted for demonstration; do not collect full prompts, tool inputs, or user materials by default just to show a busy state.
Cancellation is still provided through run options; there is no need to rebuild the Workflow:
const controller = new AbortController();
controller.abort(new Error('用户停止'));
try {
await agent.run({ question: '演示服务在线吗?' }, { signal: controller.signal });
} catch (error) {
console.log('本次运行已停止');
}const controller = new AbortController();
controller.abort(new Error('用户停止'));
try {
await agent.run({ question: '演示服务在线吗?' }, { signal: controller.signal });
} catch (error) {
console.log('本次运行已停止');
}This example cancels before entering the run, to confirm the boundary. Real HTTP or UI adapters should trigger the corresponding signal when the user disconnects, stops, or a deadline arrives, and pass the same signal down to dependencies.
6. NestJS: reuse definitions, isolate execution per request
It runs, but it is still a one-off script on the command line. Next, put it into NestJS: reuse the same definitions and isolate execution per request. An existing NestJS project does not need a different SDK style. Import createServiceAgent as the application assembly function and start each execution inside a singleton service.
In this example, each request creates a lightweight Engine and tool runtime, while the fixed Workflow, tool catalog, and Provider without request state are reused by the module. The observer, diagnostics, and tool authorization context are bound to their own requests, preserving lifecycle isolation between requests.
import { Injectable, Module } from '@nestjs/common';
import { createServiceAgent } from './service-agent.js';
@Injectable()
export class AgentService {
async ask(question: string, signal?: AbortSignal): Promise<string> {
const agent = createServiceAgent();
return agent.run({ question }, { signal });
}
}
@Module({ providers: [AgentService], exports: [AgentService] })
export class AgentModule {}import { Injectable, Module } from '@nestjs/common';
import { createServiceAgent } from './service-agent.mjs';
export class AgentService {
async ask(question, signal) {
const agent = createServiceAgent();
return agent.run({ question }, { signal });
}
}
Injectable()(AgentService);
export class AgentModule {}
Module({ providers: [AgentService], exports: [AgentService] })(AgentModule);The JS version uses Nest's decorator function calls, because plain .mjs does not directly support TypeScript decorator syntax. Both export the same service and module.
In your existing controller, inject AgentService and call ask. The controller owns HTTP input validation, user identity, quota, disconnect signals, and the response protocol; AgentService owns App assembly; the Workflow owns the business flow. Do not put a ModelRunner bound to the first user's signal, permissions, or logging target into a global singleton and share it with everyone.
If a tool depends on the current user identity, pass the server-authenticated context to tools.bind({ app, execution }) in the App's assembly function and check it in the tool's authorize. Do not treat an "admin" flag passed in by the client as authorization.
7. When integrating a real model, replace only the Provider
The offline path works end to end; all that remains is replacing the fake model with a real one. Next, replace only the Provider and touch nothing else. Once the offline flow works, swap the Provider in service-agent for the adapter you actually configured. Do not change the Workflow's model.invoke, the state updates, or the way the final result is delivered.
The example below uses the Chat Completions–compatible adapter provided by Support; the specific URL, model name, and capabilities must come from your vendor configuration:
import { Support } from '@jangts/agent-core';
const { AGENT_MODEL_BASE_URL, AGENT_MODEL_API_KEY, AGENT_MODEL_NAME } = process.env;
if (!AGENT_MODEL_BASE_URL || !AGENT_MODEL_API_KEY || !AGENT_MODEL_NAME) {
throw new Error('请先提供实际模型连接配置');
}
const provider = new Support.CompatibleModelProvider({
config: {
baseUrl: AGENT_MODEL_BASE_URL,
apiKey: AGENT_MODEL_API_KEY,
model: AGENT_MODEL_NAME,
},
});import { Support } from '@jangts/agent-core';
const { AGENT_MODEL_BASE_URL, AGENT_MODEL_API_KEY, AGENT_MODEL_NAME } = process.env;
if (!AGENT_MODEL_BASE_URL || !AGENT_MODEL_API_KEY || !AGENT_MODEL_NAME) {
throw new Error('请先提供实际模型连接配置');
}
const provider = new Support.CompatibleModelProvider({
config: {
baseUrl: AGENT_MODEL_BASE_URL,
apiKey: AGENT_MODEL_API_KEY,
model: AGENT_MODEL_NAME,
},
});The offline example's Provider turns controlled JSON into a string. With a real model, the App must provide explicit instructions, use tool receipts as data, and check whether the final answer meets business requirements; do not treat this offline template as a validated production prompt or a vendor capability test.
Keep the API Key in the server-side environment configuration, not in browser bundles or commit history. Real tools must replace the example constants with actual service calls and perform input, output, and authorization checks.
Where to add things when requirements grow
| New requirement | Where to add it in the existing structure |
|---|---|
| Explain the error after a failed lookup | The node's onErrorNodeId and an explicit error node; cancellation is not a successful fallback |
| Choose different answers based on status | The same node's routes, using route.toIfEqual or a raw route object |
| Let the model choose available capabilities | Registered capabilities, schemas, and transports; the selection result enters ordinary nodes and routes |
| Add knowledge with sources | The App chooses a Kit such as Knowledge, reads a snapshot, and makes it available to nodes |
| Input exceeds the budget | Add an explicit context processing node and keep using the same model call and final budget check |
| Turn part of the implementation into a custom class | Implement the same node/Provider/tool contract and plug it in directly; conversion to plain objects is not forced |
| Record the call chain | The App registers the allowed log output and holds the correlation; nodes do not manage the log tree themselves |
You can check specific inputs, outputs, and recommended policies in the layered API catalog. Add configuration as needed when adding features; the existing definitions, bindings, run, and way of getting results can keep being used.
Use MockGPT to debug model integration
Before connecting a real model, verify the wiring with MockGPT. The tutorial's plain-object Provider is good for understanding nodes. When you need to check HTTP/SSE, chunks, and Core parsing boundaries together, use the Kit's fetch adapter; the Workflow and Agent call style stays unchanged.
import { ModelRunner, Support } from '@jangts/agent-core';
import { MockGPT } from '@jangts/agent-core/mock';
const mock = new MockGPT({ rules: [{ generator: '演示服务在线。' }] });
const provider = new Support.CompatibleModelProvider({
config: { baseUrl: 'https://mock.local/v1', apiKey: 'offline', model: 'demo' },
ports: { request: mock.fetch },
});
console.log((await new ModelRunner({ provider }).invoke('服务状态')).value);import { ModelRunner, Support } from '@jangts/agent-core';
import { MockGPT } from '@jangts/agent-core/mock';
const mock = new MockGPT({ rules: [{ generator: '演示服务在线。' }] });
const provider = new Support.CompatibleModelProvider({
config: { baseUrl: 'https://mock.local/v1', apiKey: 'offline', model: 'demo' },
ports: { request: mock.fetch },
});
console.log((await new ModelRunner({ provider }).invoke('服务状态')).value);To let other processes call the same mock model, import createMockServer from @jangts/agent-core/mock/node. For minimal service, failure sequence, and trusted web source load examples, see the MockGPT Kit and Examples and Policies.