#!/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);
});