Skip to content

TypeScript

The OpenClaw plugin ships a small, dependency-free REST client, adapters/openclaw/client.ts. It has no OpenClaw imports, so you can use it from any TypeScript or JavaScript agent: Mastra, LangGraph.js, the Vercel AI SDK, or plain code.

Install

The client is not published as a separate npm package. Copy client.ts into your project (it is one file, MIT licensed):

curl -fsSLo src/myelinate.ts \
  https://raw.githubusercontent.com/notaaaronfr/myelinate/main/adapters/openclaw/client.ts

It needs a runtime with global fetch, AbortSignal.timeout and crypto.randomUUID: Node 20+, Bun, Deno, or edge runtimes.

The snippets import it as ./myelinate, which suits bundlers and tsx. When Node runs TypeScript natively or you compile with moduleResolution: NodeNext, import ./myelinate.ts and enable allowImportingTsExtensions (as the plugin's own tsconfig.json does).

API

import { GatewayClient } from "./myelinate";

const learner = new GatewayClient(
  process.env.MYELINATE_URL ?? "http://localhost:8080",   // base URL; a path prefix is kept
  process.env.MYELINATE_API_KEY,                          // optional bearer key (myl_...)
  4000,                                                   // timeout per request in ms (default 4000)
);
Method REST call
recall(scope, query, sessionId?) POST /v1/recall → RecallResponse: {recall_id, instructions, skills, skill_index, memories, profile, holdout}
record(episode) POST /v1/episodes → {episode_id, reflection_scheduled}. Adds an idempotency_key unless you pass one.
feedback(episodeId, score, comment?, correction?) POST /v1/feedback
latestEpisode(agentId, sessionId) GET /v1/episodes/latest → episode id or null
remember(scope, content, kind?) POST /v1/memories; kind is "fact" (default), "preference" or "lesson"
skillMarkdown(agentId, name) GET /v1/skills/{name}/SKILL.md → text or null
ask(scope, question) POST /v1/ask → {answer, evidence}

scope is { agent_id: string; user_id?: string | null }. Requests retry twice on network errors, 5xx and 429. Errors are thrown as Error("myelinate <status>: <body>").

The client throws on failure; it does not fail open by itself. Wrap recall so the agent still runs when the gateway is down, and don't await record on the response path.

A reusable wrapper

The examples below share this helper. It recalls before the run, fails open, and records after, including failures:

import { GatewayClient, type Outcome, type Scope } from "./myelinate";

const learner = new GatewayClient(process.env.MYELINATE_URL ?? "http://localhost:8080", process.env.MYELINATE_API_KEY);

export interface TurnResult {
  text: string;
  outcome?: Outcome;                                       // default "unknown"
  verification?: { passed: boolean; details?: string };    // ground truth, if you have it
  toolCalls?: { name: string; arguments?: unknown; result?: string | null; error?: string | null }[];
}

export async function learningTurn(
  scope: Scope, task: string, sessionId: string,
  run: (instructions: string) => Promise<TurnResult>,
): Promise<TurnResult & { episode: Promise<string | null> }> {
  let recallId: string | null = null;
  let instructions = "";
  try {
    const r = await learner.recall(scope, task, sessionId);
    recallId = r.recall_id;
    instructions = r.instructions;
  } catch (err) {
    console.warn("[myelinate] recall failed, running without learned context:", err);
  }

  const started = Date.now();
  let result: TurnResult | undefined;
  let error: string | null = null;
  try {
    result = await run(instructions);
    return { ...result, episode: record() };
  } catch (err) {
    error = String(err);
    void record();
    throw err;
  }

  function record(): Promise<string | null> {
    return learner.record({
      scope, session_id: sessionId, recall_id: recallId, task,
      messages: [{ role: "user", content: task }, ...(result ? [{ role: "assistant", content: result.text }] : [])],
      tool_calls: result?.toolCalls ?? [],
      outcome: error ? "failure" : (result?.outcome ?? "unknown"),
      verification: result?.verification ? { ...result.verification, source: "verifier" } : null,
      error, duration_ms: Date.now() - started, framework: "my-ts-app",
    }).then((out) => out.episode_id)
      .catch((err) => { console.warn("[myelinate] record failed:", err); return null; });
  }
}

episode resolves to the episode id once the upload finishes; use it for feedback:

const turn = await learningTurn(scope, task, sessionId, run);
const episodeId = await turn.episode;
if (episodeId) await learner.feedback(episodeId, 0.0, "wrong region", "Deploy to eu-west-1, not us-east-1.");

Vercel AI SDK

import { generateText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { learningTurn } from "./learning";

const scope = { agent_id: "docs-assistant", user_id: userId };

const turn = await learningTurn(scope, question, sessionId, async (learned) => {
  const { text } = await generateText({
    model: anthropic("claude-opus-5-5"),
    system: `You answer questions about our API.\n\n${learned}`,
    prompt: question,
  });
  return { text };
});

Mastra

Mastra's generate accepts an extra system message per call, so the agent definition stays unchanged:

import { Agent } from "@mastra/core/agent";
import { learningTurn } from "./learning";

const support = new Agent({
  name: "support",
  instructions: "You are a support agent for an online shop.",
  model: "anthropic/claude-opus-5-5",
});

const turn = await learningTurn({ agent_id: "support", user_id: customerId }, message, threadId,
  async (learned) => {
    const res = await support.generate(message, { system: learned });
    return { text: res.text };
  });

LangGraph.js / LangChain.js

import { createAgent } from "langchain";
import { learningTurn } from "./learning";

const turn = await learningTurn({ agent_id: "research-bot" }, task, threadId, async (learned) => {
  const agent = createAgent({
    model: "anthropic:claude-opus-5-5",
    tools: myTools,
    systemPrompt: `You are a research assistant.\n\n${learned}`,
  });
  const result = await agent.invoke({ messages: [{ role: "user", content: task }] });
  return { text: String(result.messages.at(-1)?.content ?? "") };
});

Exposing skill_view and remember as tools

Give the model the same two tools the Python adapters add, using your framework's tool helper. With the Vercel AI SDK, for example:

import { tool } from "ai";
import { z } from "zod";

const loaded: string[] = [];   // pass as skills_loaded when recording, so loaded skills get credit

const learningTools = {
  skill_view: tool({
    description: "Load the full instructions of a learned skill by name (listed under 'Other learned skills').",
    inputSchema: z.object({ name: z.string() }),
    execute: async ({ name }) => {
      const md = await learner.skillMarkdown("docs-assistant", name);
      if (md) loaded.push(name);
      return md ?? `No skill named '${name}'.`;
    },
  }),
  remember: tool({
    description: "Save one short durable memory: 'fact', 'preference' (about the user) or 'lesson'. Never secrets.",
    inputSchema: z.object({ content: z.string(), kind: z.enum(["fact", "preference", "lesson"]).optional() }),
    execute: async ({ content, kind }) => {
      await learner.remember({ agent_id: "docs-assistant", user_id: userId }, content, kind ?? "fact");
      return "Saved.";
    },
  }),
};

To credit loaded skills, include skills_loaded: loaded in the episode you record.

OpenTelemetry instead

If your TypeScript agent already emits OpenTelemetry GenAI spans, point the OpenTelemetry JavaScript OTLP/HTTP trace exporter (JSON or protobuf) straight at the gateway for recording; see OpenTelemetry. You still need recall for the agent to use what was learned.