Skip to content
5 changes: 5 additions & 0 deletions .changeset/tidy-wombats-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added a TaskMarket action provider for typed task discovery, wallet-authenticated submissions, and capped x402 relay payments.
1 change: 1 addition & 0 deletions typescript/agentkit/src/action-providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export * from "./morpho";
export * from "./opensea";
export * from "./spl";
export * from "./superfluid";
export * from "./taskmarket";
export * from "./sushi";
export * from "./truemarkets";
export * from "./twitter";
Expand Down
40 changes: 40 additions & 0 deletions typescript/agentkit/src/action-providers/taskmarket/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# TaskMarket action provider

`TaskMarketActionProvider` gives AgentKit agents a small, typed integration with
the TaskMarket REST API.

## What it provides

- `list_tasks` and `get_task` for public task discovery.
- `create_task` for an explicitly confirmed, Base-mainnet bounty workflow.
- `my_submissions` for read-only, wallet-authenticated submission history.
- `task_submissions` for read-only requester review of a task's submissions.
- `claim_task` for signing a documented claim intent.
- `submit_text` for uploading one explicit text artifact and submitting it
through TaskMarket's upload-key flow with x402 payment handling.

Write actions are disabled by default. Enable them only after reviewing the
task and the intended artifact:

```ts
import { AgentKit } from "@coinbase/agentkit";
import { taskMarketActionProvider } from "@coinbase/agentkit";

const agentKit = await AgentKit.configureWithWallet({
walletProvider,
actionProviders: [
taskMarketActionProvider({ allowWriteActions: true }),
],
});
```

The provider never stores private keys or API tokens. It uses the configured
`EvmWalletProvider` to sign TaskMarket's read, claim, and submission messages.
It only uploads content supplied directly to `submit_text`; it does not read
local files. The final submission relay payment is capped at 1 USDC by default;
configure `maxPaymentUsdc` explicitly if a different limit is appropriate.

`create_task` requires `confirmed: true`, validates that the reward does not
exceed `maxSpendUsdc`, embeds the exact deadline, deliverables, Base network,
and spend cap in the task description, and uses x402 to fund the escrow. The
provider never accepts or rejects submissions automatically.
2 changes: 2 additions & 0 deletions typescript/agentkit/src/action-providers/taskmarket/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./schemas";
export * from "./taskmarketActionProvider";
95 changes: 95 additions & 0 deletions typescript/agentkit/src/action-providers/taskmarket/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { z } from "zod";

const taskId = z
.string()
.min(1)
.describe("TaskMarket task identifier, usually a 0x-prefixed 32-byte hex value");

/** Filters accepted by the TaskMarket task search endpoint. */
export const TaskMarketListTasksSchema = z
.object({
status: z.string().optional().default("open").describe("Task status, for example open"),
phase: z
.enum(["active", "in_review", "awaiting_settlement", "resolved"])
.optional()
.describe("Derived lifecycle phase"),
mode: z
.enum(["bounty", "claim", "pitch", "benchmark", "auction"])
.optional()
.describe("Task mode"),
tags: z.array(z.string()).optional().describe("Tags to match"),
rewardMin: z.number().nonnegative().optional().describe("Minimum reward in USDC"),
rewardMax: z.number().nonnegative().optional().describe("Maximum reward in USDC"),
deadlineHours: z
.number()
.int()
.positive()
.optional()
.describe("Only tasks expiring within this many hours"),
limit: z.number().int().positive().max(100).optional().default(20),
cursor: z.string().optional().describe("Pagination cursor returned by a previous call"),
})
.describe("Filters for searching public TaskMarket tasks");

/** Input for retrieving a single task. */
export const TaskMarketGetTaskSchema = z.object({ taskId }).describe("TaskMarket task lookup");

/** Input for claiming a task as the current EVM wallet. */
export const TaskMarketClaimTaskSchema = z.object({ taskId }).describe("TaskMarket claim request");

/** Inputs for creating and funding a new Base-mainnet TaskMarket bounty. */
export const TaskMarketCreateTaskSchema = z
.object({
description: z.string().trim().min(1).describe("Human-readable task description"),
deliverables: z
.array(z.string().trim().min(1))
.min(1)
.max(20)
.describe("Concrete deliverables the worker must provide"),
rewardUsdc: z.number().positive().describe("Escrowed reward in USDC"),
deadlineIso: z
.string()
.min(1)
.refine(value => Number.isFinite(Date.parse(value)), "deadlineIso must be an ISO date"),
network: z.literal("base-mainnet").describe("TaskMarket settlement network"),
maxSpendUsdc: z.number().nonnegative().describe("Maximum USDC the caller authorizes"),
confirmed: z
.literal(true)
.describe("Fresh explicit confirmation that the displayed task details may be funded"),
tags: z.array(z.string().trim().min(1)).max(20).optional().describe("Task tags"),
})
.describe(
"Create a TaskMarket bounty only after showing description, reward, deadline, deliverables, Base network, and maximum spend to the user",
);

/** Input for retrieving submissions owned by the current EVM wallet. */
export const TaskMarketMySubmissionsSchema = z.object({
taskId: taskId.optional().describe("Optionally restrict results to one task"),
});

/** Input for retrieving every submission to a requester-owned task. */
export const TaskMarketTaskSubmissionsSchema = z
.object({ taskId })
.describe("TaskMarket requester submission review request");

/**
* A single text artifact. Text is intentionally explicit so an agent does not upload
* arbitrary local files without the caller's knowledge.
*/
export const TaskMarketSubmitTextSchema = z
.object({
taskId,
fileName: z
.string()
.min(1)
.regex(/^[^\\/]+$/, "fileName must not contain path separators")
.describe("Public artifact filename"),
content: z.string().min(1).describe("Text content to submit as the artifact"),
mimeType: z.string().min(1).optional().default("text/plain"),
role: z
.enum(["preview", "source", "final", "attachment"])
.optional()
.default("final")
.describe("TaskMarket artifact role"),
})
.describe("Submit one explicitly provided text artifact to TaskMarket");
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { TaskMarketActionProvider } from "./taskmarketActionProvider";
import { EvmWalletProvider } from "../../wallet-providers";

jest.mock("../../wallet-providers", () => {
/** Minimal wallet-provider base for the isolated provider tests. */
class WalletProvider {}

/** Minimal EVM wallet provider for the isolated provider tests. */
class EvmWalletProvider extends WalletProvider {}
return { WalletProvider, EvmWalletProvider };
});

const mockResponse = (body: unknown, status = 200): Response =>
({
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
}) as Response;

const mockWallet = {
getAddress: () => "0x1111111111111111111111111111111111111111",
getName: () => "test-wallet",
getNetwork: () => ({ protocolFamily: "evm", networkId: "base-mainnet", chainId: "8453" }),
toSigner: () => ({
address: "0x1111111111111111111111111111111111111111",
sign: jest.fn(),
signMessage: jest.fn(),
signTransaction: jest.fn(),
signTypedData: jest.fn(),
}),
readContract: jest.fn(),
signMessage: jest.fn(async (message: string) => `signature:${message}`),
} as unknown as jest.Mocked<EvmWalletProvider>;

describe("TaskMarketActionProvider", () => {
beforeEach(() => {
mockWallet.signMessage.mockClear();
});

afterEach(() => {
jest.restoreAllMocks();
});

it("rejects an invalid x402 payment cap", () => {
expect(() => new TaskMarketActionProvider({ maxPaymentUsdc: -1 })).toThrow(
"maxPaymentUsdc must be a non-negative finite number",
);
});

it("lists tasks through the public API", async () => {
const fetchMock = jest
.spyOn(global, "fetch")
.mockResolvedValue(mockResponse({ tasks: [{ id: "task-1" }], hasMore: false }));
const provider = new TaskMarketActionProvider({ apiUrl: "https://taskmarket.test" });

const result = await provider.listTasks({ status: "open", limit: 5 });

expect(result).toContain('"task-1"');
expect(fetchMock).toHaveBeenCalledWith(
"https://taskmarket.test/api/tasks?status=open&limit=5",
expect.objectContaining({ method: "GET" }),
);
});

it("requires explicit confirmation before creating a funded task", async () => {
const provider = new TaskMarketActionProvider({
apiUrl: "https://taskmarket.test",
allowWriteActions: true,
maxPaymentUsdc: 1,
});

await expect(
provider.createTask(mockWallet, {
description: "Ship a tested integration",
deliverables: ["Public pull request"],
rewardUsdc: 0.5,
deadlineIso: new Date(Date.now() + 3_600_000).toISOString(),
network: "base-mainnet",
maxSpendUsdc: 0.5,
confirmed: false as never,
}),
).rejects.toThrow("fresh explicit confirmation");
});

it("creates a confirmed task with the requested guardrails", async () => {
const fetchMock = jest
.spyOn(global, "fetch")
.mockResolvedValue(mockResponse({ taskId: "task-created" }));
const provider = new TaskMarketActionProvider({
apiUrl: "https://taskmarket.test",
allowWriteActions: true,
maxPaymentUsdc: 1,
});

const result = await provider.createTask(mockWallet, {
description: "Ship a tested integration",
deliverables: ["Public pull request", "Reproduction logs"],
rewardUsdc: 0.5,
deadlineIso: new Date(Date.now() + 3_600_000).toISOString(),
network: "base-mainnet",
maxSpendUsdc: 0.5,
confirmed: true,
tags: ["integration"],
});

expect(result).toContain("task-created");
const [input, init] = fetchMock.mock.calls[0] ?? [];
const serializedBody =
init?.body ?? (input instanceof Request ? await input.clone().text() : undefined);
const body = JSON.parse(String(serializedBody));
expect(body.reward).toBe("500000");
expect(body.description).toContain("Settlement network: Base mainnet (eip155:8453)");
expect(body.description).toContain("Maximum authorized spend: 0.5 USDC");
expect(body.description).toContain("- Public pull request");
});

it("keeps wallet writes disabled by default", async () => {
const provider = new TaskMarketActionProvider({ apiUrl: "https://taskmarket.test" });

await expect(provider.claimTask(mockWallet, { taskId: "0xabc" })).rejects.toThrow(
"write actions are disabled",
);
expect(mockWallet.signMessage).not.toHaveBeenCalled();
});

it("claims only after writes are explicitly enabled", async () => {
const fetchMock = jest
.spyOn(global, "fetch")
.mockResolvedValue(mockResponse({ claimId: "claim-1" }));
const provider = new TaskMarketActionProvider({
apiUrl: "https://taskmarket.test",
allowWriteActions: true,
});

const result = await provider.claimTask(mockWallet, { taskId: "0xabc" });

expect(result).toContain("claim-1");
expect(mockWallet.signMessage).toHaveBeenCalledWith("taskmarket:claim:0xabc");
expect(fetchMock).toHaveBeenCalledWith(
"https://taskmarket.test/api/tasks/0xabc/claim",
expect.objectContaining({ method: "POST" }),
);
});

it("retrieves task submissions with read authentication", async () => {
const fetchMock = jest
.spyOn(global, "fetch")
.mockResolvedValue(mockResponse({ submissions: [{ id: "submission-1" }] }));
const provider = new TaskMarketActionProvider({ apiUrl: "https://taskmarket.test" });

const result = await provider.taskSubmissions(mockWallet, { taskId: "0xabc" });

expect(result).toContain("submission-1");
expect(fetchMock).toHaveBeenCalledWith(
"https://taskmarket.test/api/tasks/0xabc/submissions",
expect.objectContaining({ method: "GET" }),
);
expect(mockWallet.signMessage).toHaveBeenCalledWith(
"taskmarket:read:0x1111111111111111111111111111111111111111",
);
});

it("uploads and submits one explicit text artifact", async () => {
const fetchMock = jest
.spyOn(global, "fetch")
.mockResolvedValueOnce(
mockResponse({ uploadUrl: "https://upload.test/artifact", artifactKey: "key-1" }),
)
.mockResolvedValueOnce(mockResponse(null))
.mockResolvedValueOnce(mockResponse({ submissionId: "submission-1" }));
const provider = new TaskMarketActionProvider({
apiUrl: "https://taskmarket.test",
allowWriteActions: true,
});

const result = await provider.submitText(mockWallet, {
taskId: "0xabc",
fileName: "answer.txt",
content: "hello TaskMarket",
mimeType: "text/plain",
role: "final",
});

expect(result).toContain("submission-1");
expect(mockWallet.signMessage).toHaveBeenCalledWith("taskmarket:submit:0xabc");
expect(mockWallet.signMessage).toHaveBeenCalledWith("taskmarket:submit:0xabc:key-1");
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
Loading