Nimbus Framework

ハンズオンチュートリアル


AIエージェントをゼロから構築し、ブラウザで対話できるチャットUIまで作る。
所要時間は約15分。

Nimbus 0.0.5 · 2026-06

← AIエージェント開発ガイドに戻る

必要なもの

プロジェクトを作る

@yuheinakasaka/create-nimbusでプロジェクトをスキャフォールドする。 テンプレートはchat、slack-bot、email-agent、multi-channel、minimalの5種類。 デフォルトのchatテンプレートで始める。

Terminal
npx @yuheinakasaka/create-nimbus my-agent
cd my-agent
pnpm install

以下のファイルが生成される。

my-agent/ ├── agent/ │ ├── agent.ts # エージェント定義 │ └── instructions.md # システムプロンプト ├── src/ │ └── index.ts # Workerエントリポイント ├── evals/ │ ├── eval.config.ts │ └── basic-qa.ts ├── nimbus.config.ts # プロジェクト設定 ├── wrangler.jsonc # Cloudflare Workers設定 ├── tsconfig.json └── package.json

Nimbusの設計原則のひとつはFilesystem is the APIである。 agent/tools/にファイルを置けばツールとして、agent/subagents/に置けばサブエージェントとして自動認識される。 中央のレジストリに手動で登録する作業は不要だ。

nimbus.config.ts

プロジェクトの設定はnimbus.config.tsに集約される。 @yuheinakasaka/create-nimbusが生成するデフォルトの設定は以下のとおり。

nimbus.config.ts
import { defineConfig } from "@yuheinakasaka/nimbus";

export default defineConfig({
  name: "my-agent",
  compatibility_date: "2026-06-01",
  model: {
    provider: "workers-ai",
    name: "@cf/meta/llama-4-scout-17b-16e-instruct",
  },
  dev: {
    port: 2525,
    ui: true,
  },
});

Workers AIのモデルはCloudflareの無料枠で利用できる。 外部プロバイダを使う場合は.dev.varsにAPIキーを設定し、modelを変更する。

エージェントを定義する

agent/agent.tsがエージェントの本体である。 defineAgentで使用するモデル、内部状態、ライフサイクルフックを宣言的に定義する。

agent/agent.ts
import { defineAgent } from "@yuheinakasaka/nimbus";

// エージェントが保持する状態の型
type ChatState = {
  messageCount: number;
};

export default defineAgent<ChatState>({
  // Workers AI経由でLlama 4 Scoutを使用(ツール呼び出しに対応)
  model: "workers-ai/@cf/meta/llama-4-scout-17b-16e-instruct",

  // エージェントが保持する状態
  initialState: {
    messageCount: 0,
  },

  // ツール呼び出しの最大ステップ数
  maxSteps: 10,

  // セッション開始時に呼ばれるフック
  onSessionStart: async (ctx) => {
    ctx.log("Session started");
  },

  // メッセージごとに呼ばれるフック
  onTurn: async (ctx) => {
    ctx.setState({ messageCount: ctx.state.messageCount + 1 });
    ctx.log(`Turn ${ctx.state.messageCount} started`);
  },
});

主要なオプションを確認する。

  • model — "プロバイダ/モデル名"形式。"anthropic/claude-sonnet-4-6"や"openai/gpt-4-turbo"も指定可能
  • initialState — セッション間で保持される構造化された状態
  • maxSteps — 1ターンあたりのツール呼び出し上限。無限ループを防ぐ安全弁
  • onSessionStart / onSessionEnd — ライフサイクルフック。セッションの開始・終了時に呼ばれる
  • onTurn — メッセージごとに呼ばれるフック。ターンカウントや状態更新に使う

AgentContextはstate(現在の状態)、setState(状態の更新)、sessionId、env(環境変数・バインディング)、log(ロギング)を提供する。

インストラクション

エージェントの人格と振る舞いはagent/instructions.mdに自然言語で記述する。 このMarkdownファイルがそのままシステムプロンプトになる。

agent/instructions.md
あなたは親切なAIアシスタントです。

## できること

- 都市の天気情報を調べる
- 数式を計算する
- 一般的な質問に答える

## ルール

- 日本語で回答してください
- ツールを使える場面では積極的に使ってください
- 情報が不確かな場合はその旨を伝えてください
最小構成。 defineAgentの必須オプションはmodelだけである。 最もシンプルなエージェントは5行で定義できる。 initialStateやonSessionStartは必要に応じて追加すればよい。

ツールを追加する

ツールはエージェントが「実行できるアクション」を定義する。 agent/tools/ディレクトリにファイルを配置するだけで自動登録される。 入力スキーマはZodで型安全に定義し、LLMへの仕様伝達とランタイムのバリデーションを兼ねる。

天気取得ツール

agent/tools/get-weather.ts
import { defineTool } from "@yuheinakasaka/nimbus/tools";
import { z } from "zod";

export default defineTool({
  name: "get-weather",
  description: "指定した都市の現在の天気を取得する",

  inputSchema: z.object({
    city: z.string().describe("都市名(例: 'Tokyo', 'New York')"),
  }),

  async execute({ city }) {
    // 本番ではOpenWeatherMap等のAPIを呼ぶ
    // ここではデモ用のスタブレスポンス
    const conditions = ["晴れ", "曇り", "雨", "晴れ時々曇り"];
    const condition = conditions[city.length % conditions.length];
    const tempC = 15 + (city.length % 20);

    return {
      city,
      condition,
      temperature: {
        celsius: tempC,
        fahrenheit: Math.round(tempC * 1.8 + 32),
      },
    };
  },
});

defineToolは4つの要素から構成される。 nameはツールの識別子で、LLMがツールを呼び出す際に使用する。省略した場合、ファイルパスから自動付与される(get-weather.ts → get-weather)。 descriptionはLLMがツールの用途を判断するための説明文。 inputSchemaはZodスキーマで引数の型と制約を定義する。 .describe()でフィールドの説明を付与すると、LLMがどのような値を渡すべきか判断しやすくなる。 executeは実際の処理を行う非同期関数で、戻り値はシリアライズされてLLMに返される。

計算ツール

agent/tools/calculator.ts
import { defineTool } from "@yuheinakasaka/nimbus/tools";
import { z } from "zod";

// 安全な数式評価。Cloudflare Workersランタイムは eval / new Function による
// 動的コード生成を禁止しているため、小さな再帰下降パーサで計算する。
function evaluateExpression(input: string): number {
  let pos = 0;
  const skipWs = () => { while (pos < input.length && /\s/.test(input[pos])) pos++; };
  const peek = () => { skipWs(); return input[pos]; };

  function parseExpression(): number {
    let value = parseTerm();
    while (peek() === "+" || peek() === "-") {
      const op = input[pos++];
      value = op === "+" ? value + parseTerm() : value - parseTerm();
    }
    return value;
  }
  function parseTerm(): number {
    let value = parseFactor();
    while (peek() === "*" || peek() === "/" || peek() === "%") {
      const op = input[pos++];
      const rhs = parseFactor();
      value = op === "*" ? value * rhs : op === "/" ? value / rhs : value % rhs;
    }
    return value;
  }
  function parseFactor(): number {
    const ch = peek();
    if (ch === "+") { pos++; return parseFactor(); }
    if (ch === "-") { pos++; return -parseFactor(); }
    if (ch === "(") {
      pos++;
      const value = parseExpression();
      if (peek() !== ")") throw new Error("括弧が閉じられていません");
      pos++;
      return value;
    }
    const start = pos;
    while (pos < input.length && /[0-9.]/.test(input[pos])) pos++;
    if (pos === start) throw new Error(`予期しない文字: '${ch ?? "EOF"}'`);
    return Number(input.slice(start, pos));
  }

  const result = parseExpression();
  if (peek() !== undefined) throw new Error(`予期しない文字: '${input[pos]}'`);
  return result;
}

export default defineTool({
  name: "calculator",
  description: "数式を計算して結果を返す",

  inputSchema: z.object({
    expression: z.string().describe("計算式(例: '2 + 3 * 4')"),
  }),

  async execute({ expression }) {
    const sanitized = expression.replace(/[^0-9+\-*/().%\s]/g, "");
    if (sanitized !== expression) {
      return { error: "無効な文字が含まれています" };
    }

    try {
      const result = evaluateExpression(sanitized);
      if (!Number.isFinite(result)) {
        return { error: `計算できませんでした: ${expression}` };
      }
      return { expression, result };
    } catch {
      return { error: `計算できませんでした: ${expression}` };
    }
  },
});
Workersランタイムの制約。 ツールのコードはCloudflare Workers上で実行される。Workersはevalやnew Functionによる動的コード生成を禁止しているため、文字列を式として評価するには上記のような安全なパーサを自前で用意する。外部ライブラリ(mathjs等)を使う場合はnodejs_compatと互換性があるか確認する。

この時点で、ディレクトリ構造は以下のようになっている。

agent/ ├── agent.ts ├── instructions.md └── tools/ ├── get-weather.ts # 天気取得 └── calculator.ts # 計算
ツールの自動認識。 agent/tools/に新しい.tsファイルを追加し、nimbus devを再起動すると、エージェントがすぐにそのツールを使えるようになる。 ファイル名からハイフンケースの名前が自動付与される(get-weather.ts → get-weatherツール)。

開発サーバーで動かす

ここまでで動くエージェントが完成している。 開発サーバーを起動して実際に対話してみる。

Terminal
pnpm dev

# もしくは直接:
# npx nimbus dev --port 2525

nimbus devはagent/ディレクトリをスキャンし、エージェント定義・ツール・インストラクションを自動で検出してCloudflare Workers開発サーバーを起動する。 ブラウザでhttp://localhost:2525を開くと、組み込みの開発UIが表示される。 テキストを入力してエージェントとリアルタイムに会話できる。

会話例

以下は天気取得ツールと計算ツールが連動する対話の例である。

You
東京の天気を教えて
tool
get-weather を呼び出し → { city: "Tokyo", condition: "曇り", temperature: { celsius: 20 } }
AI
東京の現在の天気は曇りで、気温は20°C(68°F)です。
You
華氏を摂氏に変換する式は?あと、100°Fは何度?
tool
calculator を呼び出し → { expression: "(100 - 32) * 5 / 9", result: 37.78 }
AI
華氏から摂氏への変換式は (°F - 32) × 5/9 です。100°Fは約37.8°Cになります。

エージェントはユーザーの質問を解釈し、どのツールをどのような引数で呼ぶかを自律的に判断している。 ツールの実行結果を受け取ったら、それを人間が読める形に整形して回答する。 これがReActパターンによるエージェントの動作ループである。

チャットUIを構築する

組み込みの開発UIでも十分だが、独自のフロントエンドを作りたい場合はnimbus-reactパッケージが提供するReactフックを使う。 ReactのUIはViteでビルドし、nimbus devで起動したエージェントのバックエンドにWebSocketで接続する。

Terminal
pnpm add @yuheinakasaka/nimbus-react react react-dom
pnpm add -D vite @vitejs/plugin-react @types/react @types/react-dom

このチュートリアルでは、エージェントのバックエンド(nimbus dev、ポート2525)と、 ReactのUIを配信するViteの開発サーバー(ポート5173)の2つのプロセスを並行して動かす。 UIはフックを通じてバックエンドに接続する。まずVite用の設定ファイルを追加する。

vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
  server: { port: 5173 },
});
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>My Agent</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
src/main.tsx
import { createRoot } from "react-dom/client";
import App from "./App";

const container = document.getElementById("root");
if (!container) throw new Error("Root element #root not found");

createRoot(container).render(<App />);

あわせてpackage.jsonのscriptsにUI起動用のコマンドを追加する。

package.json
{
  "scripts": {
    "dev": "nimbus dev",
    "dev:ui": "vite",
    "deploy": "nimbus deploy"
  }
}

useAgent / useAgentChat

useAgentがWebSocket接続の管理を担い、useAgentChatがメッセージ履歴・入力状態・ローディング状態・ツール承認フローを一括で提供する。

src/App.tsx
import { useEffect, useRef } from "react";
import { useAgent, useAgentChat } from "@yuheinakasaka/nimbus-react";

export default function App() {
  // nimbus dev のバックエンド(:2525)に接続する。
  // 本番では同一オリジンになるため host は省略できる。
  const agent = useAgent({
    agent: "chat",
    host: import.meta.env.DEV ? "localhost:2525" : undefined,
  });
  const {
    messages, input, handleInputChange,
    handleSubmit, isLoading, error,
  } = useAgentChat({ agent });

  const endRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    endRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  return (
    <div style={{ maxWidth: 640, margin: "0 auto", padding: 24 }}>
      <h1>My Agent</h1>
      <p>{agent.connected ? "接続中" : "接続待ち..."}</p>

      <div style={{
        minHeight: 300, border: "1px solid #ddd",
        borderRadius: 8, padding: 16, marginBottom: 16,
      }}>
        {messages.map((msg) => (
          <div key={msg.id} style={{
            marginBottom: 12,
            textAlign: msg.role === "user" ? "right" : "left",
          }}>
            {msg.toolCalls?.map((tc) => (
              <div key={tc.toolCallId} style={{
                fontFamily: "monospace", fontSize: 12,
                color: "#6d5a00", background: "#fff8e1",
                border: "1px solid #f0e0a0", borderRadius: 6,
                padding: "4px 8px", marginBottom: 4,
              }}>
                🔧 {tc.toolName}({JSON.stringify(tc.args)})
                {tc.result ? ` → ${JSON.stringify(tc.result)}` : ""}
              </div>
            ))}
            <div style={{
              display: "inline-block",
              padding: "8px 12px",
              borderRadius: 12,
              background: msg.role === "user"
                ? "#3968A8" : "#f0f0f0",
              color: msg.role === "user"
                ? "#fff" : "#000",
              maxWidth: "80%", whiteSpace: "pre-wrap",
            }}>
              {msg.content || (isLoading ? "考え中..." : "")}
            </div>
          </div>
        ))}
        <div ref={endRef} />
      </div>

      {error && (
        <p style={{ color: "red" }}>{error.message}</p>
      )}

      <form onSubmit={handleSubmit}
            style={{ display: "flex", gap: 8 }}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="メッセージを入力..."
          disabled={!agent.connected}
          style={{
            flex: 1, padding: 8,
            borderRadius: 6,
            border: "1px solid #ccc",
          }}
        />
        <button type="submit"
                disabled={isLoading || !agent.connected}>
          {isLoading ? "..." : "送信"}
        </button>
      </form>
    </div>
  );
}

Workerのエントリポイントsrc/index.tsは@yuheinakasaka/create-nimbusが生成した状態のままで動作する。 nimbus devを使う場合、ツールの登録は自動で行われるためこのファイルの変更は不要である。

src/index.ts
import { createNimbusAgent, createWorkerHandler } from "@yuheinakasaka/nimbus";
import { createWorkersAI } from "workers-ai-provider";
import agentDef from "../agent/agent.js";

// nimbus dev 使用時はagent/tools/のツールが自動登録される。
// wrangler deploy(本番)ではここでツールをimportしてtoolsに追加する。

type Env = { AI: Ai; Chat: DurableObjectNamespace };

export const Chat = createNimbusAgent<Env>({
  definition: agentDef as any,
  tools: [],
  instructions: "You are a helpful AI assistant.",
  createModel: (env: Env) =>
    createWorkersAI({ binding: env.AI })("@cf/meta/llama-4-scout-17b-16e-instruct"),
});

export default createWorkerHandler({
  agents: { Chat },
  routes: [],
});

あとは2つのターミナルでバックエンドとUIをそれぞれ起動する。

Terminal 1 — エージェント (:2525)
pnpm dev
Terminal 2 — React UI (:5173)
pnpm dev:ui

ブラウザでhttp://localhost:5173を開く。ヘッダーに「接続中」と表示されれば、 UIがWebSocket経由でエージェントに接続できている。メッセージを送ると、Chapter 4と同じく 天気・計算ツールが呼び出され、結果が画面にストリーミング表示される。

承認ワークフロー。 useAgentChatはpendingApprovals、approve、rejectも返す。 ツール定義でneedsApproval: "always"を設定すれば、実行前にUIで承認を求めるHuman-in-the-Loopフローを追加できる。

完成したプロジェクトの全体像

my-agent/ ├── agent/ │ ├── agent.ts │ ├── instructions.md │ └── tools/ │ ├── get-weather.ts │ └── calculator.ts ├── src/ │ ├── App.tsx # React チャットUI │ ├── main.tsx # React エントリポイント │ └── index.ts # Worker エントリポイント ├── evals/ │ ├── eval.config.ts │ └── basic-qa.ts ├── index.html # Vite HTML エントリ ├── vite.config.ts # React UI 用 Vite 設定 ├── nimbus.config.ts ├── wrangler.jsonc ├── tsconfig.json └── package.json

次のステップ

ここまでで、カスタムツールを持つAIエージェントと、それと対話できるReactチャットUIが動いている。 本番環境にデプロイする場合は以下のコマンドを実行する。

Terminal
# プレビュー環境にデプロイ
npx nimbus deploy --preview

# 本番にデプロイ
npx nimbus deploy

さらに発展させるための機能を紹介する。 各機能の詳細はAIエージェント開発ガイドの該当章を参照のこと。

  • subagents 専門エージェントにタスクを委譲する。リサーチ担当、要約担当など役割を分割できる
  • fibers チェックポイント付きの長時間ワークフロー。リトライ、スリープ、イベント待機を宣言的に記述する
  • schedules cron式でエージェントを定期実行する。毎朝のレポート生成、定期データ取得などに使う
  • channels Slack、メール、Webhookなど複数チャネルからの入力を統一的に処理する
  • connections MCP、OAuth、APIキーによる外部サービス連携。GitHub、データベースなどに接続する
  • evals エージェントの応答品質を自動テストする。CIに組み込んでリグレッションを防ぐ