Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

Build an offline agent

Add a native LangChain agent and reusable tool, test offline, then observe a typed thread.

Add a greeting assistant to your existing RELKIT app. It combines a native LangChain model, native middleware and tool, and a RELKIT function-backed tool. The small example runs without a database, network request, or paid model call.

Create the files below, or update them if they already exist. Run commands from your app’s root. The minimal app does not include the optional native execution packages. Install @relkit/langchain and its selected SDK peers first; @relkit/local supplies the explicit profiles used below.

1. Install the native packages

From this guide’s application/package root, install only its selected requirements:

bun add @langchain/core@1.2.9 @langchain/langgraph@1.4.14 @relkit/langchain@0.7.13 @relkit/local@0.7.12 langchain@1.5.10 langsmith@0.10.8

Keep these owner-specific versions and the lockfile together. Runtime SDKs belong in dependencies; build/type tooling belongs in devDependencies. Installing an adapter does not supply its optional SDK peers or enable the feature’s configuration.

The minimal starter already has development-only @relkit/testing; its generic harness does not provide the native engine. The offline test below imports invokeAgent from @relkit/langchain.

2. Configure the runtime

Extend the existing relkit.config.ts with the localRealtime/localAgentState imports, realtime and agent-state profiles, and matching entries in defaults. This small maintained example shows the required bindings. Preserve your existing environment, server settings, defaults and other provider profiles when merging it:

/** Selects agent runtime bindings without enabling an inspector tool or bridge. */
import { defineApp } from "@relkit/app/config";
import { localAgentState, localRealtime } from "@relkit/local";
import env from "@app/platform/env.js";
// relkit:create:deployment-imports

export default defineApp({
  env,
  realtime: localRealtime(),
  "agent-state": localAgentState(),
  defaults: { realtime: "default", "agent-state": "default" },
  telemetry: { redaction: { mode: "development-redacted", maxBytes: 65_536 } },
  server: { port: 3000, maxBodyBytes: 1_048_576, apiDocs: { ui: "scalar" } },
  // relkit:create:deployment
});

The two default profiles are explicit local choices for this exercise. They do not install or enable an Inspector, and they are not a production persistence/deployment selection.

3. Add the greeting function

/** Returns a validated named greeting and logs each invocation through RELKIT's request context. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";

const hello = defineFunction({
  id: "hello.greet",

  input: z.object({ name: z.string().min(1).default("world") }),
  output: z.object({ message: z.string() }),

  /**
   * Logs the greeting invocation and returns the validated greeting response.
   * @param input - Validated name from the function schema.
   * @param context - RELKIT invocation context owning structured logging.
   * @returns Greeting response matching the output schema.
   */
  handler: async ({ name }, context) => {
    context.log.info("hello invoked", { name });

    return { message: `Hello, ${name}!` };
  },
});

export default hello;

4. Make it a tool

/** Exposes the hello function as a read-only greeting tool with its existing two-second timeout. */
import hello from "@app/services/hello/functions/hello.function.js";

export default hello.asTool({
  id: "hello.lookup",
  description: "Read a greeting for a supplied name",

  sideEffect: "read",
  approval: "never",
  timeoutMs: 2_000,
});

The tool tells the model what the function does. Its input and output come from the function.

5. Add the agent

/** Runs an offline greeting agent with RELKIT/native tools, todo middleware and public state observation. */
import { defineAgent } from "@relkit/langchain";
import { z } from "@relkit/app/schema";
import { FakeToolCallingModel, todoListMiddleware, tool } from "langchain";
import lookup from "@app/services/hello/tools/lookup.tool.js";

export const uppercase = tool(async ({ text }: { readonly text: string }) => text.toUpperCase(), {
  name: "uppercase",
  description: "Uppercase text without leaving the process",
  schema: z.object({ text: z.string() }),
});

export default defineAgent({
  id: "hello.assistant",

  input: z.object({ message: z.string().min(1) }),
  output: z.object({ answer: z.string() }),

  model: new FakeToolCallingModel({
    toolCalls: [
      [
        {
          name: "write_todos",
          args: { todos: [{ content: "Answer the greeting", status: "completed" }] },
          id: "todo-1",
        },
      ],
      [
        {
          name: "relkit_output",
          args: { value: { answer: "Hello from the offline LangChain model." } },
          id: "output-1",
        },
      ],
    ],
  }),
  instructions: "Answer greeting questions with the available tools.",
  tools: [lookup, uppercase],
  middleware: [todoListMiddleware()],
  stateProfile: "default",
  client: {
    public: true,
    state: ["todos"],
    events: { notice: z.object({ message: z.string() }) },
  },
  chat: { input: "message", output: "answer" },
  controls: ["stop"],

  limits: {
    maxSteps: 4,
    maxToolCalls: 4,
    timeoutMs: 10_000,
  },
});

The agent can use the RELKIT hello.lookup tool and native uppercase tool. Its real todoListMiddleware() state is selected for the typed client, and each run has limits on steps, calls, and time.

6. Test without a model key

The test scripts a tool call and a final answer, while running the real greeting function:

/** Checks assistant.agent through the supported public RELKIT APIs. */
import { expect, test } from "bun:test";
import { invokeAgent } from "@relkit/langchain";
import assistant, { uppercase } from "@app/services/hello/agents/assistant.agent.js";
import lookup from "@app/services/hello/tools/lookup.tool.js";

test("assistant runs native tools and todo middleware without a paid provider", async () => {
  expect(assistant.tools).toEqual([expect.objectContaining({ ref: lookup.ref }), uppercase]);
  expect(assistant.middleware.map((middleware) => middleware.name)).toEqual(["todoListMiddleware"]);
  expect(assistant.client?.state).toEqual(["todos"]);
  expect(assistant.limits).toEqual({ maxSteps: 4, maxToolCalls: 4, timeoutMs: 10_000 });

  await expect(
    invokeAgent({
      agent: assistant,
      input: { message: "Greet Ada" },
      threadId: "test:assistant",
      tools: [lookup],
      engine: { invoke: async () => ({ message: "Hello, Ada!" }) },
    }),
  ).resolves.toEqual({ answer: "Hello from the offline LangChain model." });
});
bun test ./tests/unit/assistant.agent.test.ts

Expect a passing test with the answer Hello from the offline LangChain model. This checks native model execution, todo middleware, and RELKIT tool registration; it does not measure how a production model interprets questions.

Optional: call it from a typed client

This frontend is optional. Complete React client installation in a separate frontend package before copying its code. The fullstack sources are a maintained reference, not an offered creation tuple; the backend tutorial continues to use your existing app.

Keep caller-owned thread IDs separate from internal execution IDs:

export const agentThreadIds = {
  assistant: "demo:assistant",
  review: "demo:review",
} as const;

export const agentTransports = ["sse", "websocket"] as const;
export type AgentTransport = (typeof agentTransports)[number];

The fullstack example uses that identity for every run, reconnect, stop, and resume call:

Complete typed-agent UI

Optional: use a production model

Skip this step if you only want the offline example. Install the LangChain model package for your provider, construct its compatible chat model in server code, and pass that instance or a lazy factory to model. Provider credentials remain server-only and live calls may cost money. For MCP only, use the function-backed tool without adding an agent.

Do not put a key in the agent’s instructions or commit it to source control. Tests should continue to inject or declare deterministic native models explicitly; no environment name silently selects a paid provider.

bun run check
bun run dev

Optionally follow the separate Inspector installation and bridge setup. After starting that tool independently, open its UI, normally at http://localhost:3210. Under Agents, select hello.assistant to inspect its tools, middleware state, and execution. To let an external AI client call the greeting function directly, follow Tools over MCP.