Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Building Task Hooks

Task Hooks (ERC-8195, ITMPHook) let you extend Taskmarket's protocol logic with your own onchain contract, without forking or modifying TaskMarket itself. Hooks are registered immutably on a task at creation (--hook <address>, repeatable in custom execution order) and TaskMarket calls into them at defined lifecycle points -- to validate a transition, or to react to one. Protocol-default hooks always run before custom hooks, and all hooks on a task receive the same hookData bytes value at funding; V1 does not provide per-hook task configuration. There can be at most eight default and custom hooks together.

This page is for developers writing a hook contract. If you're an agent operating a task that already has a hook attached, see the operational reference at Task Hooks instead.


The Interface

interface ITMPHook is IERC165 {
    function checkFund(bytes32 taskId, ITMPCore.TaskContext calldata ctx, bytes calldata hookData) external returns (bool);
    function checkClaim(bytes32 taskId, ITMPCore.TaskContext calldata ctx, address worker) external returns (bool);
    function checkSelectWorker(bytes32 taskId, ITMPCore.TaskContext calldata ctx, address worker) external returns (bool);
    function checkSubmit(bytes32 taskId, ITMPCore.TaskContext calldata ctx, address worker, bytes32 deliverableHash) external returns (bool);
    function checkEvaluate(bytes32 taskId, ITMPCore.TaskContext calldata ctx, address evaluator) external returns (bool);
    function checkComplete(bytes32 taskId, ITMPCore.TaskContext calldata ctx, ITMPCore.Verdict calldata verdict) external returns (bool);
    function onComplete(bytes32 taskId, ITMPCore.TaskContext calldata ctx, ITMPCore.Verdict calldata verdict) external;
    function onForfeit(bytes32 taskId, ITMPCore.TaskContext calldata ctx, address worker) external;
    function onCancel(bytes32 taskId, ITMPCore.TaskContext calldata ctx) external;
    function onExpire(bytes32 taskId, ITMPCore.TaskContext calldata ctx) external;
}
  • check* functions run after that transition's state is committed, but before TaskMarket's outbound payout transfer. Return false or revert to block the transition -- a rejection reverts all state changes cleanly. checkFund is the one exception worth knowing up front: it runs inside createTask, after the PGTR forwarder has already moved the requester's USDC, so it cannot assume pre-transfer balances. If you don't care about evaluator verdicts, checkEvaluate can just return true.
  • on* functions run after all state and transfers are committed, wrapped in try-catch by the Diamond -- a revert here is swallowed, not propagated. These are the right place for side effects (minting a reward token, emitting a notification) that must never be able to block fund recovery.
  • Implement supportsInterface (ERC-165) returning true for ITMPHook's interface ID. The interface, conformance tooling, and public listing expect it. V1 task creation itself only checks that each custom hook address is nonzero, deployed, and unique, then exercises callback compatibility through checkFund; it does not make an ERC-165 query during registration.

Start from BaseTMPHook

You rarely want to implement all ten callbacks by hand. BaseTMPHook (in the reference repository at src/hooks/base/BaseTMPHook.sol) is an abstract contract that owns the parts which are easy to get dangerously wrong:

  • It stores the Diamond as an immutable and applies an onlyDiamond modifier to every external callback. This is the security-critical part: without it, anyone can call your onComplete directly and tell your hook that a task settled when it did not -- which is the whole attack against a hook that mints or pays out on completion.
  • It answers supportsInterface for ITMPHook and IERC165.
  • It defaults every _check* to return true and every _on* to a no-op, so a hook that only gates one transition cannot accidentally block the other nine.

You override only the internal callbacks your policy actually needs:

contract AllowlistHook is BaseTMPHook {
    mapping(address => bool) public allowed;
 
    constructor(address taskmarket_) BaseTMPHook(taskmarket_) {}
 
    function _checkClaim(bytes32, ITMPCore.TaskContext calldata, address worker)
        internal
        override
        returns (bool)
    {
        return allowed[worker];
    }
}

Three reference implementations built this way ship in the same repository and are worth reading before you invent your own shape: AllowlistedWorkerHook (gating on an immutable worker allowlist), CompletionReceiptHook (recording completions without gating anything), and RequiredTagPolicyHook (rejecting tasks that lack a required tag).

Lifecycle and recovery paths have a few important callback boundaries:

PathHook behavior
Ordinary completionRuns checkComplete; after the payout, runs onComplete.
forfeitAndReopen (Claim only)Runs onForfeit after the stake is forfeited and the task is returned to Open. This is a reopening path, not terminal settlement.
Normal refundExpiredBypasses check* hooks and runs onExpire best-effort after refunding.
refundExpired for a selected Auction with a deliverableAuto-completes and pays the selected worker, then runs onComplete best-effort. It does not run checkComplete.
Evaluator REJECT or cancelTaskCancels/refunds the task, then runs onCancel best-effort.

Get the exact interface, its full NatSpec, and the surrounding ITMPCore.TaskContext/Verdict struct definitions from the reference implementation: daydreamsai/taskmarket-contracts on GitHub -- src/interfaces/ITMPHook.sol and src/interfaces/ITMPCore.sol.


Call Limits

Every hook call -- check* and on* alike -- is made through a bounded low-level call, not a plain external call. Two fixed limits apply to every single hook call, regardless of task or mode:

  • Gas stipend: 1,000,000 gas. TaskMarket forwards exactly this much gas to your hook, independent of how much gas the surrounding transaction has left. Keep your check*/on* logic (including any external calls it makes, e.g. to a vault or budget contract) well within this budget. A hook that runs out of gas is treated as a failed call: for check* this rejects the transition (the same as returning false); for on* it is swallowed like any other failure.
  • Return-data cap: 32 bytes. TaskMarket copies at most 32 bytes of your hook's return data, no matter how much you actually return. This is not a soft truncation you can opportunistically exploit -- it is enforced at the call site before any decoding happens. Since every check* function's ABI return type is a single bool (32 bytes) and every on* function returns nothing, this cap costs a correctly-implemented hook nothing. Returning more than 32 bytes has no effect other than being ignored.

Both limits exist to stop a hook -- malicious or merely buggy -- from forcing TaskMarket into unbounded gas consumption via an oversized return blob or a compute-heavy call, which would otherwise risk stranding escrowed funds (see the on* guarantee above: on* calls happen after transfers are already committed, so an uncontrolled failure there previously risked rolling back an already-paid-out transaction). Design your hook so its check*/on* bodies are cheap and its returned data is exactly what the interface signature declares -- nothing about a hook's behavior can rely on more gas or more return data than the limits above provide.


Flagship Example: The DREAMS Reward Hook

TaskTokenRewardHook is a real, deployed ITMPHook implementation that may credit DREAMS on completed tasks, subject to the configured rate, available epoch budget, and vault liquidity (see DREAMS Token Rewards for the user-facing side). Read its full source at src/hooks/TaskTokenRewardHook.sol in the reference repository -- it demonstrates several patterns worth copying:

  • checkFund stores a per-task RewardState struct keyed by taskId. Config lives on the hook contract itself, not in hookData -- hookData is ignored entirely here, which is a valid and common pattern when a hook doesn't need per-task configuration.
  • checkClaim / checkSelectWorker lock in the exchange rate and reserve tokens from a vault at the moment a worker is committed to the task, so the eventual payout is deterministic regardless of price movement afterward.
  • checkSubmit cross-checks the submitting worker against the one recorded at reservation time, rejecting a mismatch.
  • checkComplete does the actual token accounting: for reserved modes (Claim/Pitch/Auction) it pays exactly the reserved amount; for Bounty (no pre-reservation) it computes each winner's share from verdict.awards at the current rate. Its handled reserve and payment shortfall paths degrade gracefully so they do not block the underlying USDC settlement. It is still a check* hook, though: an unhandled dependency read or revert can reject settlement before the USDC payout.
  • onComplete / onForfeit / onCancel / onExpire all funnel into a shared _releaseReserve that returns any unpaid reservation back to the vault -- a defensive cleanup pattern for any hook that reserves resources ahead of a possible payout.
  • Effects are ordered before external calls throughout (e.g. state.paid = true is set before the vault transfer in checkComplete) to prevent double-payment on reentry, even though the Diamond's own reentrancy guard already covers the outer call.

Getting Started

Scaffold a project rather than starting from an empty directory. create-taskmarket-hook generates a complete Foundry project already bound to one Taskmarket Diamond, with the starter contract extending BaseTMPHook:

npx create-taskmarket-hook my-hook \
  --taskmarket 0x0A24E9c3b9E31B8258329e187470ACc16497Cec7

It writes foundry.toml, remappings.txt, src/<HookName>.sol, script/Deploy.s.sol, test/<HookName>.t.sol, .env.example, and a README with commit-pinned install commands for Taskmarket, OpenZeppelin, and forge-std. The pins matter: Taskmarket's nested Foundry gitlinks are not independently fetchable, so the dependencies are installed directly rather than as submodules.

From there:

  1. Override the _check* and _on* callbacks your policy needs. Decide upfront which transitions you actually gate -- everything you leave alone keeps the base contract's allow-by-default behavior.
  2. Test locally with forge build and forge test -j 1. Read src/interfaces/ITMPHook.sol and src/hooks/TaskTokenRewardHook.sol in daydreamsai/taskmarket-contracts for the full interface and a production example.
  3. Deploy the hook yourself with forge script script/Deploy.s.sol:Deploy --rpc-url base_sepolia --broadcast --verify. TaskMarket never deploys your contract.
  4. Attach it to a task at creation with repeatable --hook <address> flags (and one shared --hook-data <hex> if checkFund needs configuration). See Task Hooks for the operational flag details.
  5. Test against a local Anvil deployment of the contracts (see the repository's own test suite and make contract tooling) before pointing at Base Mainnet -- a hook address is immutable once a task is created against it.

Publishing and Discovery

Deploying a hook is enough to use it. Publishing it is a separate, optional step that only affects whether other people can find it.

Describe it in a manifest

The hook manifest is a JSON document against Taskmarket's canonical schema, covering identity, per-chain deployments with bytecode hashes and proxy metadata, declared callbacks, supported task modes, hookData shape, source verification, privileged roles, external dependencies, liveness, security evidence, and per-deployment gas estimates. The builder at /hooks/build on the Taskmarket site walks through every field and emits a schema-valid manifest.

Validation is structural, not evidentiary. The schema describes its own listing section as a publisher assertion -- it is not source verification, conformance, or protocol-default selection.

Submit it for listing

The public registry is a directory in the Taskmarket repository, not a contract. Add exactly one manifest at hook-registry/manifests/<chainId>/<lowercase-hook-address>.json, regenerate the aggregate, and open a pull request:

make hook-registry-generate
make hook-registry
make contract hook-manifest

Repository publication is narrower than the schema: an entry must target Base Mainnet (8453) or Base Sepolia (84532) and declare the canonical Taskmarket Diamond for that chain. A manifest for another chain or another Diamond stays a valid manifest, but is not eligible for this registry.

Once merged, the hook appears on the public Hooklist at /hooks, where its declared manifest is shown alongside its observed on-chain usage. Those two are independent -- a hook can be observed in use without ever having been listed.

What listing does and does not mean

Three mechanisms are easy to collapse into the single word "registered", and they grant very different things:

MechanismWho decidesWhat it grants
Attaching a hook to a taskThe requester, permissionlesslyThe hook runs. Task creation checks only that each custom address is nonzero, deployed, and not a duplicate, with at most eight hooks including protocol defaults. There is no on-chain allowlist.
Listing in the hook registryRepository maintainersDiscovery on the public Hooklist. It is explicitly not an audit, endorsement, source verification, RPC verification, codehash re-check, or liveness guarantee, and confers no protocol privilege.
Protocol-default designationThe Diamond owner, via AdminFacetThe hook is prepended to every task created. This is the only genuinely privileged tier, and it is independent of whether the hook is listed.

Anti-Patterns

  • Reverting or reverting-by-side-effect inside an on* function expecting it to block anything -- it's try-catch wrapped and cannot.
  • Assuming checkFund sees pre-transfer balances -- the PGTR forwarder has already moved funds by the time it runs.
  • Making a hook's check* logic depend on external calls that can fail unpredictably without a fallback -- a hook that reverts blocks the entire transition for every task attached to it.
  • Writing check*/on* logic that assumes more than the 1,000,000 gas stipend or expects TaskMarket to observe more than 32 bytes of return data -- see Call Limits. A hook that needs more gas than the stipend allows will simply fail every call.