---
title: Build an offline agent
description: Add a native LangChain agent and reusable tool, test offline, then observe a typed thread.
pagination: false
sidebar:
  icon: bot
---

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.

> **Before you begin**
>
> You'll need an existing RELKIT application and Bun 1.3.10 or later. The example uses an offline
> model; no model API key is required.

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. ## 1. Install the native packages [#install-the-native-packages]

    {/* Generated by apps/docs/scripts/generate.ts. Do not edit. */}

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

    ```sh
    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. ## 2. Configure the runtime [#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:

    ```ts title="relkit.config.ts — merge native agent bindings"
    /** 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. ## 3. Add the greeting function [#add-the-greeting-function]

    ```ts title="src/services/hello/functions/hello.function.ts"
    /** 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. ## 4. Make it a tool [#make-it-a-tool]

    ```ts title="src/services/hello/tools/lookup.tool.ts"
    /** 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. ## 5. Add the agent [#add-the-agent]

    ```ts title="src/services/hello/agents/assistant.agent.ts"
    /** 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. ## 6. Test without a model key [#test-without-a-model-key]

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

    ```ts title="tests/unit/assistant.agent.test.ts"
    /** 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." });
    });
    ```

    ```sh
    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 [#call-it-from-a-typed-client]

This frontend is optional. Complete [React client installation](/docs/modules/client) 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:

```ts title="web/app/agent-config.ts"
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](https://github.com/rel-kit/relkit/blob/main/templates/default/v1/fullstack/web/app/page.tsx)

## Optional: use a production model [#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](/docs/modules/ai/mcp), 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.

```sh
bun run check
bun run dev
```

Optionally follow the separate [Inspector installation and bridge setup](/docs/modules/operations/inspector).
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](/docs/modules/ai/mcp).

## Related tasks

[Expose a tool over MCP](/docs/modules/ai/mcp), or [require approval for sensitive
actions](/docs/modules/ai/approvals).
