Roles
The sub-agent worker
The worker is the process that does the work a task pays for. It owns one Hedera account, watches the subgraph for tasks assigned to that account, produces the deliverable with a real model call on the 0G Compute Router, anchors the text on the Hedera Consensus Service and completes the task on-chain with the hash of exactly that text. Settlement pays it in aUSD over the Hedera Token Service.
What the worker is
scripts/seed.js simulates sub-agents: the operator commits hashes on their behalf and no model is involved. scripts/agent-worker.js replaces that with an actual sub-agent. It signs completeTask with its own key, so the task.subAgent check in AetherisAgency (0x16fA…bc81) is exercised for real, and the deliverable it commits to is readable by anyone from the mirror node.
| Created by | scripts/agent-identity.js with the operator client from HEDERA_OPERATOR_ID / HEDERA_OPERATOR_KEY, the first time agent:worker or agent:demo runs. |
|---|---|
| Account | AccountCreateTransaction with setECDSAKeyWithAlias, 2 HBAR initial balance and setMaxAutomaticTokenAssociations(-1), so the HTS payout at settlement needs no manual associate. |
| Address | The EVM alias of the key. It equals the address an ethers Wallet derives from AGENT_WORKER_KEY, which is what the relay resolves as msg.sender. The script asserts both derivations match before anything is written; a long-zero account would not pass NotTaskOwner. |
| Stored as | AGENT_WORKER_KEY, AGENT_WORKER_ID, AGENT_WORKER_ADDRESS appended to .env with a comment. The key is never logged. |
| Gas | topUpIfLow(address, 0.7, 1) sends 1 HBAR from the operator wallet when the worker is below 0.7 HBAR; called once at start by both scripts. |
The loop
- Poll the subgraph. Every
POLL_MSthe worker queriestasks(where: { subAgent: "<address>", status: Assigned })withid,taskId,role,feeandjob { jobId specURI }. Task ids already handled in this process are skipped. Before spending anything it readsgetTask(jobId, taskId)and confirms the task is stillAssignedto this address. - Infer on 0G Compute. A short system prompt ("You are an autonomous sub-agent inside the Aetheris agency on Hedera...") and a user prompt carrying the role, the job
specURIand the fee are sent toPOST {ZG_BASE_URL}/chat/completionswithmax_tokens: 900, a 120 s timeout and three attempts with backoff on 429, 5xx and network errors. The deliverable ischoices[0].message.content, trimmed. Any failure logs and skips the task; it is picked up again on the next poll. - Anchor on HCS. The frame below is submitted to topic 0.0.10518320 through
scripts/hcs.js. If the compact JSON would exceed 3,900 bytes the text is shortened first and the hash recomputed, so the hashed string and the anchoredtextfield are always identical. - Complete the task.
completeTask(jobId, taskId, keccak256(text), topicId, sequenceNumber)is sent by the worker wallet with 600,000 gas. The contract storesresultHash, emitsTaskCompletedandHcsLogAnchoredwith the same topic and sequence number, and moves the task toCompleted. One log line per task records job, task, role, model, token usage, HCS sequence, transaction hash and its HashScan link. - Get paid at settlement. When the operator calls
settleJobthe treasury pays the fee to the worker through the HTS precompile (MicroSettlement.viaHts = true) in aUSD 0x0000…fBC1. The worker does not settle; see the limits below.
evt | "Deliverable" |
|---|---|
jobId, taskId | The task the frame completes. |
agent | The worker address (the subAgent on-chain). |
role | The task's role string, e.g. market-research. |
model, provider | The model the Router reported and "0G Compute Router". |
chars | Length of text. |
keccak256 | Hash of text; the same value is sent as resultHash. |
text | The deliverable, verbatim. This is the string that was hashed. |
ts | ISO timestamp set by the worker. |
Job briefs
A job's specURI is an opaque string to the contract, so the demo gives it a shape the worker can read: hcs://<topicId>/<sequenceNumber> points at a JobBrief frame on the audit topic, { evt: "JobBrief", title, role, client, chars, keccak256, text }, where keccak256 covers exactly text. scripts/agent-demo.js anchors the brief for the chosen role (scripts/briefs.js ships one per role; --title and --brief-file override it) before createJob, and stores the resulting URI on the job. Before inferring, the worker parses the URI, fetches the frame chunk-aware from the mirror node, checks evt and that keccak256(text) matches the frame, and puts Job brief: <title> plus the text into the user prompt. Briefs are cached per URI; if the frame is missing, malformed or fails the hash check the worker logs once and falls back to the plain specURI prompt, so a bad brief never blocks a task and never changes what is hashed and anchored.
How to run it
npm run agent:worker # node scripts/agent-worker.js - long-running, Ctrl-C to stop npm run agent:demo # node scripts/agent-demo.js - operator side, one job end to end node scripts/agent-demo.js --role technical-writing # anchors the built-in brief for that role node scripts/agent-demo.js --role security-audit --brief-file brief.txt --title "Escrow review" node scripts/agent-demo.js --spec ipfs://your-spec # explicit specURI, no brief anchored
Start the worker in one terminal. It prints its address, model, subgraph URL, poll interval and HBAR balance, tops itself up from the operator if low, and then waits. In a second terminal the demo creates the identity if it does not exist yet, anchors the job brief on HCS and prints its title, sequence number and mirror node URL, approves and funds a job with 1.20 aUSD over HTS, assigns one task at 0.40 aUSD to the worker with the role you pass (default market-research), polls getTask until it is Completed (six minutes, with a reminder to start the worker if nothing happens), settles the job, and prints a summary: worker address, HCS sequence, on-chain resultHash, the deliverable text fetched back from the mirror node, hash match, the MicroSettlement amount and viaHts flag, and HashScan links for every transaction. The demo exits non-zero if the hash does not match.
ZG_API_KEY | Required. 0G Compute Router key (sk-...). Empty means the worker exits at start with a clear message. |
|---|---|
ZG_BASE_URL | Default https://router-api.0g.ai/v1. |
ZG_MODEL | Default glm-5.2. |
SUBGRAPH_URL | Default http://38.49.213.208:8100/subgraphs/name/aetheris; falls back to NEXT_PUBLIC_SUBGRAPH_URL. |
POLL_MS | Default 10000 (minimum 2000). |
HEDERA_HCS_TOPIC_ID | The topic the frame is written to; its submit key is the operator. |
How to verify a deliverable
Verification needs nothing from Aetheris. The chain holds the hash, the mirror node holds the text.
- Read
getTask(jobId, taskId)on the agency forresultHash, and theTaskCompletedevent (or the subgraph'sTask.hcsSequenceNumber) for the topic and sequence number. - Fetch
https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10518320/messages/<seq>. Themessagefield is base64. HCS messages are capped at 1,024 bytes, so a frame longer than that is split by the SDK into consecutive chunks, each with its own sequence number and achunk_info { initial_transaction_id, number, total }. The anchor points at chunk 1; whentotalis above 1, fetch the following sequence numbers with the sameinitial_transaction_id, concatenate the decoded chunks innumberorder, then parse the JSON.fetchFrameinscripts/agent-identity.js,mirrorMessageinscripts/hcs.jsand the dashboard'sreadHcsMessagesall do exactly this. - Compute
keccak256(utf8(frame.text))and compare withresultHash. Equal means the text on the mirror node is the deliverable the sub-agent committed to on-chain, byte for byte.
const { ethers } = require("ethers");
const { fetchFrame } = require("./scripts/agent-identity");
const f = await fetchFrame("0.0.10518320", seq); // reassembles chunks
const frame = JSON.parse(f.contents);
const hash = ethers.keccak256(ethers.toUtf8Bytes(frame.text));
console.log(hash === onChainResultHash, f.chunks, f.sequenceNumbers);What the match proves
That the text was fixed at the consensus timestamp of the frame and has not changed since, and that the worker committed to it on-chain. It does not prove the text is good, or that a particular model produced it: the model and provider fields are the worker's own statement, and the 0G Router's on-chain billing record is the place to corroborate that a request was made.
Honest limits
- One identity per process. The worker serves one address. Running several workers means several accounts and several
.envfiles; there is no multi-tenant mode. - Deliverable size cap. The prompt asks for about 2,500 characters and the frame is capped at 3,900 bytes, which the SDK splits into up to four 1,024-byte HCS chunks. A reader that shows one sequence number at a time (the dashboard audit stream included) sees a partial JSON for chunks of a Deliverable; only a chunk-aware reader reassembles it. Longer work would need an off-chain blob with the hash anchored.
- The Router bills per request. Every inference, including retries after a Router 5xx and re-runs of a task whose
completeTaskfailed, costs the key's on-chain balance. - The operator still settles.
settleJobandassignSubAgentareonlyOwner. The worker is paid only when the operator settles, and nothing forces that. - The HCS submit key is the operator's. Frames are written with
HEDERA_OPERATOR_KEY, so the worker and the operator share a machine or a secret. A production worker would write to its own topic, or the topic would carry a threshold submit key. - Subgraph lag. A task is seen when the subgraph has indexed
SubAgentAssigned. The on-chain check before inferring guards against stale rows, not against late ones.
