> ## Documentation Index
> Fetch the complete documentation index at: https://docs.asteragents.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Exporting Your Agents

> Take an agent you built on Aster and run it somewhere else — with the scripts to do it

## Overview

Agents you build on Aster are yours. The prompt is markdown, the skills are `SKILL.md` files, the tool definitions are JSON Schema, and the knowledge base documents are your own files. None of it is a proprietary format, and all of it comes out through the [public API](/api-reference/introduction) whenever you want it — no export request, no support ticket.

This page gives you the scripts. The first pulls an agent into a portable bundle; the second stands that bundle up on [Anthropic's Managed Agents API](https://platform.claude.com/docs/en/managed-agents/overview), running on Anthropic's infrastructure under your own API key, with Aster nowhere in the request path.

<Note>
  You need an Aster API key (Control Hub → Settings → API Access) and, for the second script, an Anthropic API key.
</Note>

## What travels and what doesn't

Being straight about this matters more than the pitch.

<CardGroup cols={2}>
  <Card title="Comes with you" icon="circle-check">
    **System prompts** — plain markdown, no wrapper.

    **Skills** — `SKILL.md` plus bundled files, in the open [Agent Skills](https://agentskills.io) format. The same format Anthropic's Skills API accepts, so they upload unchanged.

    **Tool schemas** — JSON Schema, one entry per tool.

    **Knowledge base documents** — your own source files, which you already hold.
  </Card>

  <Card title="Stays on Aster" icon="circle-minus">
    **Tool implementations.** Aster ships 200+ tool integrations — Salesforce, Snowflake, NetSuite, Egnyte, and the rest — plus retrieval, the Python sandbox, scheduling, audit logging, and app hosting. The schemas travel; the code behind them is the platform.

    On another runtime you either map a tool to a native equivalent or implement it yourself against the exported schema. The export script lists exactly which tools are in that bucket.
  </Card>
</CardGroup>

Embeddings are derived data, not source data — whatever runtime you move to regenerates them from your documents.

## Step 1: export the agent

```bash theme={null}
ASTER_API_KEY=your_key npx tsx export-agent.ts --agent 123 --out ./export
```

You get a directory like this:

```
export/deal-screener/
  SYSTEM_PROMPT.md         the agent's instructions, plain markdown
  skills/
    deal-memo-format/
      SKILL.md
      templates/memo.docx
  tools.json               JSON Schema for every tool the agent has
  knowledge-bases.json     which KBs the agent reads
  agent.json               the full Aster record, verbatim
  manifest.json            what travels, what doesn't, the model mapping
  README.md
```

The `skills/` directory is deliberately laid out to match `.claude/skills/` — commit it to a repository and Anthropic's Managed Agents will discover those skills automatically when the repo is mounted on a session, with no upload step at all.

<Accordion title="export-agent.ts">
  ```typescript theme={null}
  #!/usr/bin/env npx tsx
  /**
   * Export an Aster agent into a portable, open-format bundle.
   *
   * Usage:
   *   ASTER_API_KEY=... npx tsx export-agent.ts --agent 613 [--out ./export]
   *
   * Requires: npm install jszip tsx
   */

  import { writeFileSync, mkdirSync } from "node:fs";
  import { join, dirname } from "node:path";
  import JSZip from "jszip";

  const API_KEY = process.env.ASTER_API_KEY;
  const BASE = argValue("--base") ?? "https://www.asteragents.com/api";
  const AGENT_ID = argValue("--agent");
  const OUT_ROOT = argValue("--out") ?? "./agent-export";

  function argValue(flag: string): string | undefined {
    const i = process.argv.indexOf(flag);
    if (i !== -1 && process.argv[i + 1]) return process.argv[i + 1];
    return process.argv.find((a) => a.startsWith(`${flag}=`))?.slice(flag.length + 1);
  }

  if (!API_KEY) {
    console.error("ASTER_API_KEY is not set. Control Hub → Settings → API Access.");
    process.exit(1);
  }
  if (!AGENT_ID) {
    console.error("--agent <id> is required.");
    process.exit(1);
  }

  async function fetchApi(path: string): Promise<Response> {
    const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${API_KEY}` } });
    if (!res.ok) throw new Error(`GET ${path} → ${res.status} ${await res.text()}`);
    return res;
  }
  const api = async (path: string): Promise<any> => (await fetchApi(path)).json();
  const apiBinary = async (path: string): Promise<Buffer> =>
    Buffer.from(await (await fetchApi(path)).arrayBuffer());

  const slug = (s: string) =>
    s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);

  // Aster models are `provider:model-id`. Anthropic-hosted runtimes are
  // Anthropic-only, so anything else is a substitution — say so out loud.
  function mapModel(asterModel: string | null): { model: string; note: string | null } {
    if (!asterModel) return { model: "claude-opus-5", note: "Agent had no model set; defaulted to claude-opus-5." };
    const [provider, ...rest] = asterModel.split(":");
    if (provider === "anthropic") return { model: rest.join(":"), note: null };
    return {
      model: "claude-opus-5",
      note: `Agent runs ${asterModel} on Aster. Anthropic-hosted runtimes are Anthropic-only, so the bundle targets claude-opus-5 — re-tune the prompt if you switch model families.`,
    };
  }

  async function main() {
    const agents = await api("/agents");
    const agent = agents.find((a: any) => String(a.id) === String(AGENT_ID));
    if (!agent) {
      console.error(`Agent ${AGENT_ID} not found in this organization.`);
      process.exit(1);
    }

    const dir = join(OUT_ROOT, slug(agent.name));
    mkdirSync(dir, { recursive: true });
    console.log(`Writing to ${dir}`);

    // 1. The agent record and the system prompt, verbatim.
    writeFileSync(join(dir, "agent.json"), JSON.stringify(agent, null, 2));
    writeFileSync(join(dir, "SYSTEM_PROMPT.md"), agent.systemPrompt ?? "");

    // 2. Tool schemas. Portable JSON Schema; the code behind them stays on Aster.
    const tools: Record<string, any> = agent.tools ?? {};
    const toolNames = Object.keys(tools);
    writeFileSync(join(dir, "tools.json"), JSON.stringify(tools, null, 2));

    // 3. Skills. Aster serves each as a zip with its files at the root; unpack
    //    into skills/<name>/ so the result is exactly the `.claude/skills/` layout.
    const skillIds: number[] = tools.load_skill?.config?.skillIds ?? [];
    const allSkills = (await api("/skills/manage")).skills ?? [];
    // An empty allowlist means the agent can load any skill in the org.
    const skillsToExport =
      skillIds.length > 0 ? allSkills.filter((s: any) => skillIds.includes(s.id)) : allSkills;

    const skillManifest: Array<{ id: number; name: string; dir: string; files: string[] }> = [];
    for (const s of skillsToExport) {
      const zip = await JSZip.loadAsync(await apiBinary(`/skills/download?id=${s.id}`));
      const skillDir = join(dir, "skills", slug(s.name));
      const written: string[] = [];
      for (const [name, entry] of Object.entries(zip.files)) {
        if (entry.dir) continue;
        const dest = join(skillDir, name);
        mkdirSync(dirname(dest), { recursive: true });
        writeFileSync(dest, await entry.async("nodebuffer"));
        written.push(name);
      }
      if (!written.includes("SKILL.md")) {
        console.log(`  warning: skill "${s.name}" has no SKILL.md at its root`);
      }
      skillManifest.push({ id: s.id, name: s.name, dir: `skills/${slug(s.name)}`, files: written.sort() });
      console.log(`  skills/${slug(s.name)}/ (${written.length} files)`);
    }

    // 4. Knowledge bases. The source documents are your own files — this records
    //    which ones the agent reads so the mapping isn't lost.
    const kbIds: number[] = tools.search_knowledge_base?.config?.accessibleKnowledgeBaseIds ?? [];
    const allKbs = (await api("/kb/manage")).knowledgeBases ?? [];
    const kbs = kbIds.length > 0 ? allKbs.filter((k: any) => kbIds.includes(k.id)) : allKbs;
    writeFileSync(
      join(dir, "knowledge-bases.json"),
      JSON.stringify(
        kbs.map((k: any) => ({
          id: k.id, name: k.name, description: k.description,
          embeddingModel: k.embeddingModel, fileCount: k.fileCount, source: k.source,
        })),
        null, 2,
      ),
    );

    // 5. Manifest — the honest inventory of what travels and what doesn't.
    const { model, note } = mapModel(agent.model);
    if (note) console.log(`  note: ${note}`);
    writeFileSync(
      join(dir, "manifest.json"),
      JSON.stringify({
        exportedAt: new Date().toISOString(),
        source: { platform: "Aster Agents", agentId: agent.id, agentName: agent.name, model: agent.model },
        target: { model, modelNote: note },
        portable: {
          systemPrompt: "SYSTEM_PROMPT.md — plain markdown",
          skills: skillManifest,
          toolSchemas: "tools.json — JSON Schema, uploadable as custom tool definitions",
        },
        notPortable: {
          toolImplementations: toolNames,
          note:
            "Tool schemas travel; the code behind them does not. Aster's tool implementations " +
            "(integration clients, retrieval, sandbox, scheduling, audit logging, app hosting) run " +
            "on Aster. On another runtime you either map a tool to a native equivalent or implement " +
            "it yourself against the schema in tools.json.",
        },
        knowledgeBases: {
          note:
            "Knowledge base source documents are your own files — export them from your source system " +
            "or from Control Hub. Embeddings are derived data and are re-generated by whatever runtime " +
            "you move to.",
        },
      }, null, 2),
    );

    console.log(`\nDone. ${skillManifest.length} skills, ${toolNames.length} tool schemas, ${kbs.length} KBs.`);
  }

  main().catch((err) => {
    console.error(err.message ?? err);
    process.exit(1);
  });
  ```
</Accordion>

## Step 2: run it on Anthropic

```bash theme={null}
ANTHROPIC_API_KEY=your_key npx tsx run-on-anthropic.ts ./export/deal-screener "Screen the Q3 pipeline"
```

The mapping is close to one-to-one:

| Bundle                                         | Anthropic Managed Agents                                                    |
| ---------------------------------------------- | --------------------------------------------------------------------------- |
| `SYSTEM_PROMPT.md`                             | `system` on `POST /v1/agents`                                               |
| `skills/<name>/`                               | `POST /v1/skills`, then `skills: [{type: "custom", skill_id}]` on the agent |
| `tools.json`                                   | `{type: "custom", name, input_schema}` tool definitions on the agent        |
| Aster's Python sandbox, file tools, web search | the prebuilt `agent_toolset_20260401`                                       |

Every tool the prebuilt toolset doesn't cover comes back to your code as a custom tool call. The script prints that list on startup and returns an explicit "not implemented" for each one — a stub that lies about its result would be worse than no stub at all. `handleCustomTool` is where your implementations go.

<Accordion title="run-on-anthropic.ts">
  ```typescript theme={null}
  #!/usr/bin/env npx tsx
  /**
   * Run an exported Aster bundle on Anthropic's Managed Agents API.
   *
   * Usage:
   *   ANTHROPIC_API_KEY=... npx tsx run-on-anthropic.ts <bundle-dir> "first message"
   *
   * Options:
   *   --agent-id <id>   Reuse an existing agent instead of creating one.
   *   --dry-run         Print what would be created and exit.
   *
   * Requires: npm install @anthropic-ai/sdk jszip tsx
   */

  import Anthropic, { toFile, type Uploadable } from "@anthropic-ai/sdk";
  import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
  import { join, relative, basename } from "node:path";
  import JSZip from "jszip";

  const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
  const BUNDLE = args[0];
  const FIRST_MESSAGE = args[1];
  const DRY_RUN = process.argv.includes("--dry-run");
  const agentIdFlag = process.argv.indexOf("--agent-id");
  const EXISTING_AGENT_ID = agentIdFlag === -1 ? undefined : process.argv[agentIdFlag + 1];

  if (!BUNDLE || !existsSync(BUNDLE)) {
    console.error('Usage: npx tsx run-on-anthropic.ts <bundle-dir> "first message"');
    process.exit(1);
  }

  const manifest = JSON.parse(readFileSync(join(BUNDLE, "manifest.json"), "utf8"));
  const systemPrompt = readFileSync(join(BUNDLE, "SYSTEM_PROMPT.md"), "utf8");
  const asterTools = JSON.parse(readFileSync(join(BUNDLE, "tools.json"), "utf8"));

  const client = new Anthropic();

  // Tools the destination runtime already handles, so re-declaring them as custom
  // tools would give the model two ways to do one job. The prebuilt agent toolset
  // covers file, shell, and web work; skills are attached natively via the
  // agent's `skills` array, so `load_skill` has no job here either.
  const HANDLED_NATIVELY = new Set([
    "execute_python", "read_file", "scrape_url", "ask_web", "search_google", "load_skill",
  ]);

  function walk(dir: string): string[] {
    return readdirSync(dir).flatMap((entry) => {
      const full = join(dir, entry);
      return statSync(full).isDirectory() ? walk(full) : [full];
    });
  }

  // Anthropic's Skills API wants every file under one top-level directory with
  // SKILL.md at its root. The bundle stores skills unpacked, so zip each one back
  // up under its own directory name.
  async function zipSkill(skillDir: string): Promise<Uploadable> {
    const name = basename(skillDir);
    const zip = new JSZip();
    for (const file of walk(skillDir)) {
      zip.file(join(name, relative(skillDir, file)), readFileSync(file));
    }
    const buf = await zip.generateAsync({ type: "nodebuffer" });
    return toFile(buf, `${name}.zip`, { type: "application/zip" });
  }

  // Your tool implementations go here. Every tool in tools.json that the agent
  // toolset doesn't cover arrives as a custom tool call and the session waits on
  // this function. Returning "not implemented" is honest and keeps the loop
  // moving; returning a fabricated result is not.
  function handleCustomTool(name: string, input: unknown): string {
    return `Tool "${name}" is not implemented in this runtime. Its schema came from Aster; the implementation did not. Input was: ${JSON.stringify(input)}`;
  }

  async function main() {
    const customTools = Object.entries(asterTools)
      .filter(([name]) => !HANDLED_NATIVELY.has(name))
      .map(([name, schema]: [string, any]) => ({
        type: "custom" as const,
        name,
        description: schema?.description ?? name,
        input_schema: schema?.parameters ?? schema?.input_schema ?? { type: "object", properties: {} },
      }));

    const skillsRoot = join(BUNDLE, "skills");
    const skillDirs = existsSync(skillsRoot)
      ? readdirSync(skillsRoot).map((d) => join(skillsRoot, d)).filter((d) => statSync(d).isDirectory())
      : [];

    console.log(`Bundle: ${BUNDLE}`);
    console.log(`  model:  ${manifest.target.model}${manifest.target.modelNote ? "  (substituted)" : ""}`);
    if (manifest.target.modelNote) console.log(`          ${manifest.target.modelNote}`);
    console.log(`  system: ${systemPrompt.length} chars`);
    console.log(`  skills: ${skillDirs.length}`);
    console.log(`  tools:  ${customTools.length} custom + the prebuilt agent toolset`);
    if (customTools.length > 0) {
      console.log(`          not implemented here: ${customTools.map((t) => t.name).join(", ")}`);
    }
    if (DRY_RUN) return;

    // 1. Upload each skill. SKILL.md + bundled files go up unchanged.
    const skillRefs: Array<{ type: "custom"; skill_id: string; version: string }> = [];
    for (const d of skillDirs) {
      const skill = await client.skills.create({ files: [await zipSkill(d)] });
      skillRefs.push({ type: "custom", skill_id: skill.id, version: "latest" });
      console.log(`Uploaded skill ${basename(d)} → ${skill.id}`);
    }

    // 2. Create the agent once. In production this is a setup step and the ID
    //    gets stored — sessions reference it, they don't recreate it.
    let agentRef: string;
    if (EXISTING_AGENT_ID) {
      agentRef = EXISTING_AGENT_ID;
    } else {
      const agent = await client.beta.agents.create({
        name: manifest.source.agentName,
        model: manifest.target.model,
        system: systemPrompt,
        tools: [
          { type: "agent_toolset_20260401", default_config: { enabled: true } },
          ...customTools,
        ],
        skills: skillRefs,
      });
      agentRef = agent.id;
      console.log(`Created agent ${agent.id} (version ${agent.version})`);
    }

    if (!FIRST_MESSAGE) {
      console.log(`\nAgent is live. Run again with a message to start a session:\n  npx tsx run-on-anthropic.ts ${BUNDLE} "your message" --agent-id ${agentRef}`);
      return;
    }

    // 3. Run it.
    const environment = await client.beta.environments.create({
      name: `${manifest.source.agentName} env`,
      config: { type: "cloud", networking: { type: "unrestricted" } },
    });
    const session = await client.beta.sessions.create({
      agent: agentRef,
      environment_id: environment.id,
    });
    console.log(`Session ${session.id}\n`);

    // Open the stream before sending, so no early events are missed.
    await Promise.all([
      streamSession(session.id),
      client.beta.sessions.events.send(session.id, {
        events: [{ type: "user.message", content: [{ type: "text", text: FIRST_MESSAGE }] }],
      }),
    ]);
  }

  async function streamSession(sessionId: string) {
    while (true) {
      const stream = await client.beta.sessions.events.stream(sessionId);
      const pending: Array<{ id: string; name: string; input: unknown }> = [];

      for await (const event of stream) {
        if (event.type === "agent.message") {
          for (const block of event.content) {
            if (block.type === "text") process.stdout.write(block.text);
          }
        } else if (event.type === "agent.custom_tool_use") {
          pending.push({ id: event.id, name: event.name, input: event.input });
        } else if (event.type === "session.status_idle") {
          break;
        } else if (event.type === "session.status_terminated") {
          return;
        }
      }

      if (pending.length === 0) return;

      await client.beta.sessions.events.send(sessionId, {
        events: pending.map((call) => ({
          type: "user.custom_tool_result" as const,
          custom_tool_use_id: call.id,
          content: [{ type: "text" as const, text: handleCustomTool(call.name, call.input) }],
        })),
      });
    }
  }

  main().catch((err) => {
    console.error(err.message ?? err);
    process.exit(1);
  });
  ```
</Accordion>

## Other destinations

Nothing here is specific to Managed Agents. The same bundle drops into any runtime that reads a system prompt and Agent Skills:

* **[Claude Code](https://code.claude.com/docs) or the [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk)** on your own machines — put `SYSTEM_PROMPT.md` in `CLAUDE.md` and `skills/` in `.claude/skills/`.
* **Your own agent loop** against the Messages API, or any other provider — the prompt is markdown and the tool definitions are JSON Schema.

## Related

<CardGroup cols={2}>
  <Card title="Agent Skills" icon="graduation-cap" href="/features/skills">
    The SKILL.md format your skills are already written in.
  </Card>

  <Card title="Security" icon="shield-check" href="/security">
    Data ownership, retention, deletion, and subprocessors.
  </Card>
</CardGroup>
