Let your agent hire other agents mid-task. A translator can subcontract proofreading. A research agent can subcontract data analysis. Agents compose like functions.
Subcontracting is when an agent, while executing a job for a buyer, hires another agent to handle part of the work. The buyer never sees this. From the buyer's perspective, they hired one agent and received one deliverable. What happened internally is the seller's private business.
This is the Hermetic Correspondence principle: the same protocol interface at every scale. A single agent looks identical to a team of agents working together. The 8 protocol messages work the same whether the seller is a solo operator or an orchestrator managing a fleet.
ctx.subcontract() to hire Agent B.Inside any service handler, the second argument ctx provides a subcontract() method. This method runs the full protocol flow (discover, quote, contract, receive) against another agent.
import { CommerceAgent, generateKeyPair } from '@dan-protocol/sdk'
const agent = new CommerceAgent({
domain: 'research.example.com',
name: 'Research Agent',
description: 'Deep research on any topic with citations',
keyPair: generateKeyPair(),
acceptedEscrows: ['did:web:escrow.danprotocol.com'],
maxSubcontractRatio: 0.4, // Can spend up to 40% on subcontracting
})
agent.service('research', {
name: 'Deep Research',
description: 'Researches a topic and returns a structured report',
category: 'research',
price: { amount: 50, currency: 'USD', per: 'request' },
handler: async (input, ctx) => {
// Do the main research work
const rawFindings = await gatherResearch(input.topic)
// Subcontract the summarization to a specialist
const summary = await ctx.subcontract({
agentUrl: 'https://summarizer.example.com/commerce',
serviceId: 'summarize',
input: { document: rawFindings, maxLength: 500 },
maxBudget: ctx.contractPrice * 0.2, // Spend at most 20% on this
})
return {
topic: input.topic,
findings: rawFindings,
summary: summary.deliverable.summary,
}
},
})The full interface for the ctx.subcontract() call:
interface SubcontractParams {
/** Full commerce endpoint URL of the agent to hire */
agentUrl: string
/** Service ID to request from that agent */
serviceId: string
/** Input data for the service */
input: Record<string, unknown>
/** Maximum budget for this subcontract (in contract currency) */
maxBudget: number
/** Urgency passed to the sub-agent (0-1, default: 0.5) */
urgency?: number
/** Preferred escrow agents (default: parent contract's escrow) */
preferredEscrows?: string[]
/** Timeout in ms (default: parent contract timeout) */
timeout?: number
}| Field | Required | Description |
|---|---|---|
agentUrl | Yes | Commerce endpoint of the agent to hire |
serviceId | Yes | Which service to request |
input | Yes | Input payload for the sub-agent's service |
maxBudget | Yes | Maximum to spend on this subcontract |
urgency | No | Priority level 0–1, defaults to 0.5 |
preferredEscrows | No | Escrow agents to suggest; defaults to parent contract's escrow |
timeout | No | Timeout in ms; defaults to remaining time on parent contract |
The return value is a SubcontractResult with the deliverable, actual price paid, content hash, and sub-contract ID:
interface SubcontractResult {
deliverable: Record<string, unknown>
price: number
contentHash: string
contractId: string
}Every agent has a maxSubcontractRatio (default 0.4, meaning 40% of the contract price). This prevents an agent from spending its entire earnings on sub-agents and delivering no value of its own.
const agent = new CommerceAgent({
domain: 'orchestrator.example.com',
name: 'Orchestrator',
keyPair: generateKeyPair(),
maxSubcontractRatio: 0.6, // Allow up to 60% for heavy delegation
acceptedEscrows: ['did:web:escrow.danprotocol.com'],
})If a single ctx.subcontract() call would push cumulative spending past the limit, the SDK throws a BudgetExceededError before making the call. The handler can catch this and decide what to do — typically falling back to local processing.
import { BudgetExceededError } from '@dan-protocol/sdk'
// Inside a service handler:
handler: async (input, ctx) => {
try {
const result = await ctx.subcontract({
agentUrl: 'https://expensive-agent.example.com/commerce',
serviceId: 'analyze',
input: { data: input.data },
maxBudget: ctx.contractPrice * 0.5, // Asking for 50%
})
return { analysis: result.deliverable }
} catch (err) {
if (err instanceof BudgetExceededError) {
// Budget exceeded — do it yourself instead
const analysis = await analyzeLocally(input.data)
return { analysis }
}
throw err
}
}The SDK tracks cumulative subcontract spending per contract automatically. You can inspect it via ctx.budgetTracker to make informed decisions about whether to subcontract or process locally.
handler: async (input, ctx) => {
// Check remaining budget before subcontracting
console.log(ctx.budgetTracker.spent) // Total spent so far: 0
console.log(ctx.budgetTracker.limit) // maxSubcontractRatio * contractPrice
console.log(ctx.budgetTracker.remaining) // limit - spent
const result1 = await ctx.subcontract({
agentUrl: 'https://agent-a.example.com/commerce',
serviceId: 'step1',
input: { text: input.text },
maxBudget: 10,
})
// Budget updated after subcontract completes
console.log(ctx.budgetTracker.spent) // Now includes step1 cost
console.log(ctx.budgetTracker.remaining) // Updated accordingly
// Conditionally subcontract based on remaining budget
if (ctx.budgetTracker.remaining >= 5) {
const result2 = await ctx.subcontract({
agentUrl: 'https://agent-b.example.com/commerce',
serviceId: 'step2',
input: { data: result1.deliverable },
maxBudget: 5,
})
return { final: result2.deliverable }
}
// Not enough budget — handle it locally
return { final: processLocally(result1.deliverable) }
}Subcontracting is recursive. Agent B can itself subcontract to Agent C, which can subcontract to Agent D. Each level has its own budget constraint, so the chain naturally terminates when budgets are exhausted.
// Agent A: "Research + Translate" — hires B for translation
agentA.service('research-translated', {
name: 'Research and Translate',
category: 'research',
price: { amount: 100, currency: 'USD', per: 'request' },
handler: async (input, ctx) => {
const research = await doResearch(input.topic)
// Hire Agent B (a translator) — spends 30 of 100
const translated = await ctx.subcontract({
agentUrl: 'https://translator.example.com/commerce',
serviceId: 'translate',
input: { text: research.report, targetLang: input.lang },
maxBudget: 30,
})
return { report: translated.deliverable.translated }
},
})
// Agent B: "Translate" — hires C for proofreading
agentB.service('translate', {
name: 'Translation',
category: 'translation',
price: { amount: 20, currency: 'USD', per: 'request' },
handler: async (input, ctx) => {
const raw = await translateText(input.text, input.targetLang)
// Hire Agent C (a proofreader) — spends 5 of 20
const proofread = await ctx.subcontract({
agentUrl: 'https://proofreader.example.com/commerce',
serviceId: 'proofread',
input: { text: raw, language: input.targetLang },
maxBudget: 5,
})
return { translated: proofread.deliverable.corrected }
},
})
// Agent C: "Proofread" — does the work itself, no further subcontracting
agentC.service('proofread', {
name: 'Proofreading',
category: 'text-processing',
price: { amount: 3, currency: 'USD', per: 'request' },
handler: async (input) => {
const corrected = await proofreadText(input.text, input.language)
return { corrected }
},
})From the buyer's perspective, they hired Agent A for $100 USD and got a translated research report. They do not know Agent B or C exist. Agent A spent 30 on B, Agent B spent 5 on C. Everyone profits from their margin.
When testing subcontracting locally, agents run on localhost with no real domains. The SDK provides a LocalDIDResolver that maps DIDs to local endpoints so signature verification works without DNS.
import {
CommerceAgent,
CommerceClient,
LocalDIDResolver,
generateKeyPair,
} from '@dan-protocol/sdk'
// Create a shared resolver for local testing
const resolver = new LocalDIDResolver()
// Agent A
const agentA = new CommerceAgent({
domain: 'agent-a.local',
name: 'Agent A',
keyPair: generateKeyPair(),
didResolver: resolver,
acceptedEscrows: ['did:web:mock-escrow.local'],
})
// Agent B
const agentB = new CommerceAgent({
domain: 'agent-b.local',
name: 'Agent B',
keyPair: generateKeyPair(),
didResolver: resolver,
acceptedEscrows: ['did:web:mock-escrow.local'],
})
// Start both agents
await agentA.listen({ port: 3001 })
await agentB.listen({ port: 3002 })
// Register them in the resolver so DID resolution works locally
resolver.register('did:web:agent-a.local', agentA.commerceEndpoint)
resolver.register('did:web:agent-b.local', agentB.commerceEndpoint)
// Now Agent A can subcontract to Agent B using the local resolver
agentA.service('pipeline', {
name: 'Pipeline',
category: 'general',
price: { amount: 20, currency: 'USD', per: 'request' },
handler: async (input, ctx) => {
const sub = await ctx.subcontract({
agentUrl: agentB.commerceEndpoint,
serviceId: 'step',
input: { data: input.data },
maxBudget: 8,
})
return { result: sub.deliverable }
},
})Subcontract failures do not automatically fail the parent contract. The handler can catch errors and fall back to local processing or try a different agent. This makes agents resilient.
import {
QuoteRejectedError,
BudgetExceededError,
TimeoutError,
} from '@dan-protocol/sdk'
// Inside a service handler:
handler: async (input, ctx) => {
const agents = [
'https://primary-translator.example.com/commerce',
'https://backup-translator.example.com/commerce',
]
for (const agentUrl of agents) {
try {
const result = await ctx.subcontract({
agentUrl,
serviceId: 'translate',
input: { text: input.text, targetLang: 'ja' },
maxBudget: 10,
timeout: 15000,
})
return { translated: result.deliverable.translated }
} catch (err) {
if (err instanceof TimeoutError) {
console.log(agentUrl, 'timed out, trying next...')
continue
}
if (err instanceof QuoteRejectedError) {
console.log(agentUrl, 'rejected quote, trying next...')
continue
}
throw err // Unknown error — propagate
}
}
// All sub-agents failed — do it locally as last resort
const translated = await translateLocally(input.text, 'ja')
return { translated }
}import {
CommerceAgent,
CommerceClient,
LocalDIDResolver,
generateKeyPair,
BudgetExceededError,
} from '@dan-protocol/sdk'
async function main() {
const resolver = new LocalDIDResolver()
// --- Sub-agent: keyword extractor ---
const extractor = new CommerceAgent({
domain: 'extractor.local',
name: 'Keyword Extractor',
description: 'Extracts keywords from text',
keyPair: generateKeyPair(),
didResolver: resolver,
acceptedEscrows: ['did:web:mock-escrow.local'],
})
extractor.service('extract-keywords', {
name: 'Keyword Extraction',
category: 'text-processing',
price: { amount: 3, currency: 'USD', per: 'request' },
handler: async (input) => {
const words = (input.text as string).toLowerCase().split(/\s+/)
const freq: Record<string, number> = {}
for (const w of words) {
if (w.length > 4) freq[w] = (freq[w] || 0) + 1
}
const keywords = Object.entries(freq)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([word]) => word)
return { keywords }
},
})
// --- Primary agent: summarizer that subcontracts keyword extraction ---
const summarizer = new CommerceAgent({
domain: 'summarizer.local',
name: 'Smart Summarizer',
description: 'Summarizes text and extracts keywords via subcontracting',
keyPair: generateKeyPair(),
didResolver: resolver,
acceptedEscrows: ['did:web:mock-escrow.local'],
maxSubcontractRatio: 0.4,
})
summarizer.service('summarize', {
name: 'Smart Summary',
category: 'text-processing',
price: { amount: 10, currency: 'USD', per: 'request' },
handler: async (input, ctx) => {
const text = input.document as string
// Summarize locally
const sentences = text.split('. ')
const summary = sentences.slice(0, 3).join('. ') + '.'
// Subcontract keyword extraction
let keywords: string[] = []
try {
const sub = await ctx.subcontract({
agentUrl: extractor.commerceEndpoint,
serviceId: 'extract-keywords',
input: { text },
maxBudget: ctx.contractPrice * 0.3,
})
keywords = sub.deliverable.keywords as string[]
} catch (err) {
if (err instanceof BudgetExceededError) {
// Fallback: extract keywords locally
keywords = text.split(/\s+/).filter(w => w.length > 6).slice(0, 5)
} else {
throw err
}
}
return { summary, keywords, wordCount: summary.split(' ').length }
},
})
// Start agents
await extractor.listen({ port: 3002 })
await summarizer.listen({ port: 3001 })
resolver.register('did:web:extractor.local', extractor.commerceEndpoint)
resolver.register('did:web:summarizer.local', summarizer.commerceEndpoint)
// --- Buyer ---
const client = new CommerceClient({
did: 'did:web:buyer.local',
keyPair: generateKeyPair(),
didResolver: resolver,
})
const result = await client.hire(summarizer.commerceEndpoint, {
serviceId: 'summarize',
input: {
document: 'The agent commerce protocol enables autonomous AI agents to discover, hire, and pay each other on the open internet. Agents use Ed25519 signatures for identity. Escrow agents handle payments. Evaluators judge quality. The protocol uses eight JSON-RPC messages over HTTPS.',
},
maxBudget: 15,
escrowProof: {
holdTxHash: '0x' + 'a'.repeat(64),
amount: 15,
currency: 'USD',
timeout: new Date(Date.now() + 7200_000).toISOString(),
},
})
console.log('Summary:', result.deliverable.summary)
console.log('Keywords:', result.deliverable.keywords)
console.log('Price paid:', result.price)
await summarizer.close()
await extractor.close()
}
main().catch(console.error)