Skip to content
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"build": "tsc && node scripts/copy-plugin-assets.mjs",
"dev": "tsc --watch",
"start": "node dist/index.js",
"test": "npm run build && node --test --test-reporter=spec test/local.mjs test/skills.local.mjs test/repair.mjs",
"test": "npm run build && node --test --test-reporter=spec test/local.mjs test/skills.local.mjs test/repair.mjs test/market.local.mjs",
"test:e2e": "npm run build && node --test --test-reporter=spec test/e2e.mjs",
"test:free-models": "npm run build && node --test --test-reporter=spec test/free-model-matrix.mjs",
"test:all": "npm run test && npm run test:e2e",
Expand Down
67 changes: 66 additions & 1 deletion src/agent/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ const DIRECT_COMMANDS: Record<string, (ctx: CommandContext) => Promise<void> | v
` **Session:** /plan /ultraplan /execute /compact /retry /sessions /resume /session-search /context /tasks /history /transcript\n` +
` **Power:** /ultrathink [query] /ultraplan /noplan /moa [query] /dump\n` +
` **Info:** /model /auto /wallet /cost /tokens /learnings /brain /mcp /doctor /version /bug /help\n` +
` **Market:** /market [keyword] · /market info <slug> · /market run <slug> <input>\n` +
` **UI:** /clear /exit\n` +
skillsBlock +
(ultrathinkOn ? `\n Ultrathink: ON\n` : '')
Expand Down Expand Up @@ -638,6 +639,70 @@ export async function handleSlashCommand(
return { handled: true };
}

// /market [keyword | info <slug> | run <slug> <input>] — browse + hire paid
// skills from the BlockRun agent marketplace (business.blockrun.ai). Browsing
// and search are free GETs; `run` pays ONE standard x402 from the wallet.
if (input === '/market' || input.startsWith('/market ')) {
const arg = input.slice('/market'.length).trim();
const { fetchCatalog, runMarketSkill, formatCatalogList, formatSkillCard, fmtUsd } =
await import('../market/client.js');

// run <slug> <input> — the only paid path.
if (/^run(\s|$)/.test(arg)) {
const runMatch = arg.match(/^run\s+(\S+)\s+([\s\S]+)$/);
if (!runMatch) {
ctx.onEvent({ kind: 'text_delta', text: 'Usage: /market run <slug> <input>\n' });
emitDone(ctx);
return { handled: true };
}
const [, slug, skillInput] = runMatch;
ctx.onEvent({ kind: 'text_delta', text: `Hiring ${slug}…\n` });
const outcome = await runMarketSkill(slug, skillInput);
if (!outcome.ok) {
ctx.onEvent({ kind: 'text_delta', text: `Could not run ${slug}: ${outcome.error}. No charge.\n` });
} else {
const tx = outcome.txHash ? ` . tx ${outcome.txHash.slice(0, 12)}…` : '';
ctx.onEvent({ kind: 'text_delta', text: `Paid ${fmtUsd(outcome.paidUsd)}${tx}\n\n${outcome.result ?? ''}\n` });
}
emitDone(ctx);
return { handled: true };
}

// info <slug> — detail card.
if (/^info(\s|$)/.test(arg)) {
const slug = arg.slice('info'.length).trim();
if (!slug) {
ctx.onEvent({ kind: 'text_delta', text: 'Usage: /market info <slug>\n' });
emitDone(ctx);
return { handled: true };
}
try {
const skills = await fetchCatalog({ limit: 200 });
const skill = skills.find((s) => s.slug === slug);
ctx.onEvent({ kind: 'text_delta', text: skill ? formatSkillCard(skill) : `No skill "${slug}" in the marketplace.\n` });
} catch (err) {
ctx.onEvent({ kind: 'text_delta', text: `Could not reach the marketplace: ${(err as Error).message}\n` });
}
emitDone(ctx);
return { handled: true };
}

// (no arg) browse the top skills, or <keyword> to search — both free.
try {
const TOP = 12;
const skills = await fetchCatalog({ limit: 200, query: arg || undefined });
const shown = arg ? skills : skills.slice(0, TOP);
const heading = arg
? `Agent talents — ${shown.length} skill(s) matching "${arg}":`
: `Agent talents — top ${shown.length}${skills.length > TOP ? ` of ${skills.length}` : ''}:`;
ctx.onEvent({ kind: 'text_delta', text: formatCatalogList(shown, { heading }) });
} catch (err) {
ctx.onEvent({ kind: 'text_delta', text: `Could not reach the marketplace: ${(err as Error).message}\n` });
}
emitDone(ctx);
return { handled: true };
}

// /insights [--days N] — rich usage insights
if (input === '/insights' || input.startsWith('/insights ')) {
const daysMatch = input.match(/--days\s+(\d+)/);
Expand Down Expand Up @@ -1050,7 +1115,7 @@ export async function handleSlashCommand(
...Object.keys(REWRITE_COMMANDS),
...ARG_COMMANDS.map(c => c.prefix.trim()),
...skillNames,
'/branch', '/resume', '/model', '/auto', '/wallet', '/cost', '/help', '/clear', '/retry', '/exit', '/session-search', '/ssearch', '/failures',
'/branch', '/resume', '/model', '/auto', '/wallet', '/cost', '/help', '/clear', '/retry', '/exit', '/session-search', '/ssearch', '/failures', '/market',
];
const cmd = input.split(/\s/)[0];
const close = allCommands.filter(c => {
Expand Down
18 changes: 18 additions & 0 deletions src/agent/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,17 @@ export class PermissionManager {
return { behavior: 'ask' };
}

// agent_talent: browsing the marketplace is a free read (auto-allow);
// hiring (action="run") spends USDC from the wallet and has no refund, so
// it asks — same policy as the other paid, irreversible tools (VoiceCall,
// BuyPhoneNumber). describeAction spells out the spend in the prompt.
if (toolName === 'agent_talent') {
const action = typeof input.action === 'string' ? input.action.toLowerCase() : '';
return action === 'run'
? { behavior: 'ask' }
: { behavior: 'allow', reason: 'free marketplace browse' };
}

// Default: read-only tools are auto-allowed, others ask
if (READ_ONLY_TOOLS.has(toolName)) {
return { behavior: 'allow', reason: 'read-only default' };
Expand Down Expand Up @@ -390,6 +401,13 @@ export class PermissionManager {
}
case 'Agent':
return `Launch sub-agent: ${(input.description as string) || (input.prompt as string)?.slice(0, 80) || 'task'}`;
case 'agent_talent': {
if (((input.action as string) || '').toLowerCase() === 'run') {
const slug = (input.slug as string) || 'a skill';
return `Hire '${slug}' from the agent marketplace — pays from your wallet (USDC on Base), charged only on a successful run.`;
}
return 'Browse the agent marketplace (free).';
}
default:
return JSON.stringify(input).slice(0, 120);
}
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export const API_URLS: Record<Chain, string> = {

export const DEFAULT_PROXY_PORT = 8402;

// BlockRun agent-market (the paid skill marketplace Franklin browses with
// `/market` and hires with the agent_talent tool). It speaks standard
// single-leg `exact` x402 on Base, so Franklin pays it with the same EVM
// wallet it uses for the gateway. Overridable via env for local end-to-end
// testing against a dev server.
export const MARKET_URL = (process.env.BLOCKRUN_MARKET_URL || 'https://business.blockrun.ai').replace(/\/+$/, '');

export function saveChain(chain: Chain): void {
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
fs.writeFileSync(CHAIN_FILE, chain + '\n', { mode: 0o600 });
Expand Down
Loading