From astropods
Recipes for scaffolding and configuring a Mastra AI agent on the Astropods platform — including project skeleton, astropods.yml spec, knowledge stores, and deployment pitfalls.
How this skill is triggered — by the user, by Claude, or both
Slash command
/astropods:build-astropods-agentThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when the user asks how to build, extend, or troubleshoot an
Use this skill when the user asks how to build, extend, or troubleshoot an agent on the Astropods platform.
Be concise. Lead with the answer and the file/symbol to change. Only quote sections of this skill that are directly relevant to the question.
ast create scaffolds:
myagent/
├── agent/index.ts # entry point: Mastra agent + serve()
├── astropods.yml # spec — declares interfaces, models, knowledge, inputs
├── Dockerfile # bun:1 base, copies agent/ and skills/
├── package.json # bun + @mastra/* + @astropods/*
├── tsconfig.json
└── AGENT.md, AGENTS.md, CLAUDE.md, README.md
The built-in runtimes are either generated in Python or Bun. This skill is focused on Bun using Mastra for the agent framework.
Validate every change with ast spec validate. The full schema lives at
https://astropods.com/schema/package.json.
# yaml-language-server: $schema=https://astropods.com/schema/package.json
spec: package/v1
name: myagent
agent:
build: { context: ., dockerfile: Dockerfile }
interfaces:
messaging: true # gRPC sidecar; required for chat / cron
frontend: true # optional, if present agent must EXPOSE 80 and serve port 80
inputs:
- name: MY_API_KEY
datatype: string
secret: true
description: ...
models:
anthropic:
provider: anthropic # injects ANTHROPIC_API_KEY
knowledge:
cache:
provider: redis # built-in providers: qdrant, redis, postgres, neo4j
dev:
interfaces:
messaging:
adapters: [web, slack]
slack: { socket_mode: true, auto_thread: true }
frontend: { port: 80 }
Pitfalls:
agent.interfaces.frontend: true requires the container to bind port 80.
In production the platform always routes to 80; only override locally with
dev.interfaces.frontend.port.interfaces: MUST be nested under agent:. Top-level placement is
silently ignored.Built-in provider (preferred):
knowledge:
cache: { provider: redis }
Injects REDIS_HOST, REDIS_PORT, REDIS_URL into the agent
container. The platform also provisions a persistent volume automatically.
Custom container (use when no built-in fits):
knowledge:
qdrant:
container:
image: qdrant/qdrant:latest
port: 6333
volume: /qdrant/storage
Custom containers inject KNOWLEDGE_{NAME}_HOST / KNOWLEDGE_{NAME}_PORT
— no _URL — and you must declare the volume yourself. The naming
difference is the single biggest gotcha when an agent fails to connect.
For Postgres specifically: always read all five env vars (POSTGRES_HOST,
POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB).
POSTGRES_URL is also injected but isn't reliable with every client.
import { Agent } from '@mastra/core/agent';
import { Mastra } from '@mastra/core/mastra';
import { Memory } from '@mastra/memory';
import { LibSQLStore } from '@mastra/libsql';
import { Observability } from '@mastra/observability';
import { OtelExporter } from '@mastra/otel-exporter';
import { MastraAdapter } from '@astropods/adapter-mastra';
import { serve } from '@astropods/adapter-core';
const memory = new Memory({ storage: new LibSQLStore({ id: 'memory', url: ':memory:' }) });
const agent = new Agent({
id: 'myagent',
name: 'MyAgent',
instructions: () => renderInstructions(), // function = dynamic system prompt
model: 'anthropic/claude-sonnet-4-5',
memory,
tools: { my_tool: myTool },
defaultOptions: {
tracingOptions: {
tags: ['astro', 'agent:myagent'],
metadata: { agent_id: 'myagent' },
},
},
});
// Construct Mastra even if you call serve() directly — the constructor
// registers agents/observability plugins at startup.
new Mastra({ agents: { myagent: agent }, observability });
serve(new MastraAdapter(agent));
instructions can be string | (() => string | Promise<string>). Use the
function form when the prompt needs to reflect mutable state (loaded skills,
current user, schedule list).
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
export const myTool = createTool({
id: 'my_tool',
description: 'Plain-language description the LLM uses to decide when to call this.',
inputSchema: z.object({
name: z.string().describe('Per-argument description shown to the LLM.'),
}),
execute: async ({ name }) => {
// input fields are passed directly — no { context } wrapper (Mastra ≥ 1.x)
return { ok: true, result: '...' };
},
});
Patterns that work:
Wrap MastraAdapter (or any AgentAdapter) to add cross-cutting behavior
— slash-command parsing, auth gates, prompt rewriting, conversation context:
import type { AgentAdapter, StreamHooks, StreamOptions } from '@astropods/adapter-core';
export class MyWrapper implements AgentAdapter {
readonly name: string;
constructor(private readonly inner: AgentAdapter) {
this.name = inner.name;
if (inner.streamAudio) this.streamAudio = inner.streamAudio.bind(inner);
}
streamAudio?: AgentAdapter['streamAudio'];
async stream(prompt: string, hooks: StreamHooks, options: StreamOptions) {
// Short-circuit: hooks.onChunk('...'); hooks.onFinish(); return;
// Pass through with transformation:
return this.inner.stream(transform(prompt), hooks, options);
}
getConfig() { return this.inner.getConfig(); }
}
serve(new MyWrapper(new MastraAdapter(agent)));
options.conversationId and options.userId give you the per-message
context. To carry that into downstream code (e.g. Mastra tools), use
AsyncLocalStorage — Mastra runs the tool inside the same async context as
the wrapping adapter call.
Three patterns, depending on what you need to do.
Call agent.generate() directly on the Mastra agent. No messaging-
service round-trip; you stay inside the Mastra pipeline (memory, tools,
tracing) and get the response synchronously.
import type { Agent } from '@mastra/core/agent';
async function invoke(agent: Agent, prompt: string, convId: string) {
const result = await agent.generate(prompt, {
memory: { thread: convId, resource: 'system' },
});
return result.text;
}
Open a long-lived bidi conversation stream and send an AgentResponse
whose conversationId matches the target adapter's expected format. The
messaging service broadcasts AgentResponses with unmatched conversation
ids to all registered adapters; the adapter recognising the format
accepts and delivers.
import { MessagingClient, type ConversationStream } from '@astropods/messaging';
const client = new MessagingClient(process.env.GRPC_SERVER_ADDR || 'localhost:9090');
const ready = client.connectWithRetry({ initialDelayMs: 500, maxDelayMs: 10_000, jitter: true });
let conv: ConversationStream | null = null;
ready.then(() => {
conv = client.createConversationStream();
conv.on('error', (e) => console.error('bidi stream error', e));
});
function postToSlack(channelId: string, body: string) {
if (!conv) return; // still connecting
conv.sendAgentResponse({ conversationId: channelId, content: { type: 'REPLACE', content: body } });
conv.sendAgentResponse({ conversationId: channelId, content: { type: 'END', content: '' } });
}
REPLACE sets the buffer, END is what actually triggers the adapter
post. See section 8 for the Slack-specific details.
MessagingClient.processMessage()processMessage is a server-streaming RPC. It does not route to
bidi-connected agents the way Slack/web ingress does. Synthetic messages
dispatched this way produce empty 2–4 ms turnarounds (the agent never
processes them) and any "wait for response on our bidi stream" pattern
hangs indefinitely (verified to time out at whatever your client TTL is).
The symptom is a successful return from processMessage with zero
'response' events and a 'end' event firing immediately.
Use 7A for invocation; use 7B for outbound delivery.
Don't await connect() in your boot path. Connect with retry in the
background and gate per-call with await ready. Otherwise a slow
messaging sidecar will hang the entire agent (frontend, chat,
everything).
The platform ships a slack messaging adapter that handles both ingress
(Slack → agent) and egress (agent → Slack channel). Use it instead of
writing Slack Web API code.
agent:
interfaces: { messaging: true }
dev:
interfaces:
messaging:
adapters: [web, slack]
slack:
socket_mode: true # avoids configuring webhooks for local dev
auto_thread: true # thread bot replies under the user message
# optional ingress filters:
# actionable_reactions: [ticket]
# allowed_channel_ids: [C0123, C0456]
# allowed_user_ids: [U0789]
Bot credentials (bot token, app token for socket mode, signing secret) are
managed by the platform — set via ast project configure and injected
into the messaging sidecar, not into your agent container. Never
declare your own SLACK_BOT_TOKEN input when you're using the adapter.
Use the proactive-AgentResponse pattern (section 7B). The Slack adapter
accepts AgentResponses whose conversationId matches its format:
C0123456789 → posts top-level to channel C0123456789C0123456789-1726000000.123456 → posts as a reply in that Slack
thread (<channel-id>-<thread_ts>)REPLACE sets the message body; END triggers the post// Cron-fired post:
conv.sendAgentResponse({
conversationId: process.env.SLACK_CHANNEL!, // e.g. 'C0123456789'
content: { type: 'REPLACE', content: output },
});
conv.sendAgentResponse({
conversationId: process.env.SLACK_CHANNEL!,
content: { type: 'END', content: '' },
});
C (public), G (private), or D (DM).chat.postMessage
to a channel where the bot isn't a member returns not_in_channel and
the post is silently dropped — no error surfaces to your code. Run
/invite @<botname> in the channel first, or DM the bot
(D… channels always work).platform: 'slack'. That
does not coerce the adapter into posting; egress only happens for
AgentResponses (section 7B) whose conversationId matches.Declaring the Slack adapter means Slack users can also @<bot> or DM
the agent in any channel where the bot is installed, using the same
dispatch logic (slash routing, run_skill tool) as the web playground —
no extra code.
Use node-cron (small, works on Bun). Always:
cron.validate(expr) before scheduling.Set<string> keyed by job name.agent.generate() (section 7A) —
NOT through MessagingClient.processMessage (section 7C explains why).agent.generate() resolves.import cron from 'node-cron';
import type { Agent } from '@mastra/core/agent';
const running = new Set<string>();
async function run(agent: Agent, name: string, prompt: string) {
if (running.has(name)) return;
running.add(name);
try {
const result = await agent.generate(prompt, {
memory: { thread: `cron:${name}:${Date.now()}`, resource: 'cron-scheduler' },
});
console.log(`[cron] ${name}:`, result.text);
// Optional: push to Slack via the bidi stream (section 7B)
} catch (e) {
console.error(`[cron] ${name} failed:`, e);
} finally {
running.delete(name);
}
}
if (cron.validate(expr)) {
cron.schedule(expr, () => { void run(agent, name, prompt); });
}
Pass the agent into the scheduler module. initScheduler(agent)
must be called after the agent is constructed; otherwise the closure
holds null. See section 14.
JSDoc pitfall: a comment containing */ (common in cron examples like
*/15 * * * *) closes the block comment prematurely and breaks parsing.
Use // line comments for any docstring that mentions cron expressions.
Each agent.generate() call with a fresh conversationId creates a new
thread in Mastra's LibSQLStore. With cron firing every minute, the
in-memory store grows forever. Mastra has no built-in TTL — prune
explicitly.
Pattern:
const CRON_RESOURCE_ID = 'cron-scheduler'; // shared resourceId for filtering
const CRON_THREAD_PREFIX = 'cron:'; // for parsing timestamps
// When invoking:
await agent.generate(prompt, {
memory: {
thread: `${CRON_THREAD_PREFIX}${name}:${Date.now()}`,
resource: CRON_RESOURCE_ID,
},
});
// Periodic sweep:
setInterval(async () => {
const { threads } = await memory.listThreads({
filter: { resourceId: CRON_RESOURCE_ID }, perPage: false,
});
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const t of threads) {
const ts = Number(t.id.split(':').pop());
if (Number.isFinite(ts) && ts < cutoff) await memory.deleteThread(t.id);
}
}, 60 * 60 * 1000).unref();
Setting memory.resource to a constant for synthetic invocations gives
you a clean filter for cleanup — memory.listThreads({ filter: { resourceId } })
finds all your cron threads and nothing else. Encoding the timestamp in
the thread id (cron:<name>:<ts>) lets you age them without reading
each thread's metadata.
import { Observability } from '@mastra/observability';
import { OtelExporter } from '@mastra/otel-exporter';
function resolveOtlpEndpoint(): string {
const raw = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';
try {
const u = new URL(raw);
if (!u.pathname || u.pathname === '/') u.pathname = '/v1/traces';
return u.toString();
} catch {
return `${raw.replace(/\/+$/, '')}/v1/traces`;
}
}
const observability = new Observability({
configs: {
otel: {
serviceName: 'myagent',
exporters: [new OtelExporter({
provider: { custom: { endpoint: resolveOtlpEndpoint(), protocol: 'http/protobuf' } },
})],
},
},
});
new Mastra({ agents: { myagent: agent }, observability });
Stable per-span tags via agent.defaultOptions.tracingOptions make traces
filterable in Jaeger/Tempo/Honeycomb without extra instrumentation.
import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto';
const SESSION_TTL = 8 * 60 * 60;
const cookieSecret = process.env.SESSION_SECRET || randomBytes(32).toString('hex');
function sign(expiresAt: number): string {
const payload = `admin.${expiresAt}`;
const mac = createHmac('sha256', cookieSecret).update(payload).digest('hex');
return `${payload}.${mac}`;
}
// constant-time password compare:
function eq(a: string, b: string): boolean {
const ab = Buffer.from(a), bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
Bun.serve({ port: 80, fetch: handle });
Production filesystem is read-only. Pre-create any writable paths in
the Dockerfile (RUN touch ./backend/.env && mkdir -p ./writable-dir).
ast spec validate # check astropods.yml
ast project configure # set env vars / secrets
ast project start # boot the local stack (agent + sidecars)
ast project logs # tail container logs
ast project stop # tear down
ast docs # the canonical platform docs
The playground is at http://localhost:3100. The agent's own frontend (if
declared) is at the URL ast project start prints.
npx claudepluginhub astropods/agents --plugin astropodsCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.