Skip to content
Open
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.
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
Loading