Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions typescript/.changeset/taskmarket-agent-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added a Taskmarket action provider for discovering Base USDC work and submitting signed text artifacts without automatic 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 @@ -26,6 +26,7 @@ export * from "./opensea";
export * from "./spl";
export * from "./superfluid";
export * from "./sushi";
export * from "./taskmarket";
export * from "./truemarkets";
export * from "./twitter";
export * from "./wallet";
Expand Down
27 changes: 27 additions & 0 deletions typescript/agentkit/src/action-providers/taskmarket/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Taskmarket Action Provider

The `TaskMarketActionProvider` connects an AgentKit EVM wallet to the
[Taskmarket](https://taskmarket.dev/) worker workflow on Base mainnet.

It exposes three actions:

- `list_tasks`: discover open USDC tasks without spending funds.
- `get_task`: inspect a task, escrow transaction, deadline, and pending actions.
- `submit_work`: submit a complete text artifact after explicit user
authorization. The worker wallet signs `taskmarket:submit:<taskId>`, uploads
the artifact through Taskmarket's presigned flow, then signs the artifact-key
binding before finalizing the submission. It does not automatically pay an
X402 fee; payment-required responses are returned as errors.

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

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

Submissions are public to the requester and may be visible to other workers,
so never submit private keys, credentials, or confidential data. Re-fetch the
task before submitting and verify that it is still open and accepting work.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./schemas";
export * from "./taskmarketActionProvider";
82 changes: 82 additions & 0 deletions typescript/agentkit/src/action-providers/taskmarket/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { z } from "zod";

const TaskIdSchema = z
.string()
.regex(/^0x[a-fA-F0-9]{64}$/, "Task ID must be a 32-byte 0x-prefixed hex value")
.describe("Taskmarket task ID");

/** Input schema for discovering open Taskmarket work. */
export const TaskMarketListTasksSchema = z
.object({
mode: z
.enum(["bounty", "claim", "pitch", "benchmark", "auction"])
.nullish()
.transform(value => value ?? "bounty")
.describe("Optional task mode to filter by"),
tags: z
.array(z.string().min(1))
.max(10)
.nullish()
.transform(value => value ?? [])
.describe("Optional task tags to filter by"),
minReward: z
.string()
.regex(/^\d+(\.\d+)?$/, "Minimum reward must be a non-negative USDC amount")
.nullish()
.transform(value => value ?? undefined)
.describe("Optional minimum reward in USDC"),
deadlineHours: z
.number()
.int()
.positive()
.max(8760)
.nullish()
.transform(value => value ?? undefined)
.describe("Only return tasks expiring within this many hours"),
limit: z
.number()
.int()
.positive()
.max(50)
.nullish()
.transform(value => value ?? 20)
.describe("Maximum number of tasks to return"),
})
.strict();

/** Input schema for reading one Taskmarket task. */
export const TaskMarketGetTaskSchema = z
.object({
taskId: TaskIdSchema,
})
.strict();

/** Input schema for submitting a text artifact to a Taskmarket bounty. */
export const TaskMarketSubmitWorkSchema = z
.object({
taskId: TaskIdSchema,
fileName: z
.string()
.min(1)
.max(200)
.regex(/^[^/\\]+$/, "File name must not contain a path separator")
.describe("Name of the artifact file to submit"),
mimeType: z
.string()
.min(1)
.max(100)
.describe("MIME type of the artifact, for example text/markdown"),
content: z.string().min(1).max(2_000_000).describe("UTF-8 text content for the artifact"),
role: z
.enum(["preview", "source", "final", "attachment"])
.nullish()
.transform(value => value ?? "final")
.describe("Taskmarket artifact role"),
confirmation: z
.string()
.min(1)
.describe("User-provided confirmation that this publicly visible submission is authorized"),
})
.strict();

export { TaskIdSchema };
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { EvmWalletProvider } from "../../wallet-providers";
import { Network } from "../../network";
import { TaskMarketActionProvider, taskMarketActionProvider } from "./taskmarketActionProvider";
import { TaskMarketListTasksSchema, TaskMarketSubmitWorkSchema } from "./schemas";

const BASE_NETWORK: Network = {
protocolFamily: "evm",
networkId: "base-mainnet",
chainId: "8453",
};

const OTHER_NETWORK: Network = {
protocolFamily: "evm",
networkId: "ethereum-mainnet",
chainId: "1",
};

const wallet = {
getAddress: jest.fn(() => "0x1111111111111111111111111111111111111111"),
getNetwork: jest.fn(() => BASE_NETWORK),
signMessage: jest.fn().mockResolvedValue("0xsignature"),
} as unknown as EvmWalletProvider;

describe("TaskMarketActionProvider", () => {
const fetchMock = jest.fn();
global.fetch = fetchMock;

beforeEach(() => {
jest.resetAllMocks();
wallet.getNetwork = jest.fn(() => BASE_NETWORK);
wallet.getAddress = jest.fn(() => "0x1111111111111111111111111111111111111111");
wallet.signMessage = jest.fn().mockResolvedValue("0xsignature");
});

it("supports Base mainnet only", () => {
const provider = taskMarketActionProvider();
expect(provider.supportsNetwork(BASE_NETWORK)).toBe(true);
expect(provider.supportsNetwork(OTHER_NETWORK)).toBe(false);
expect(provider.supportsNetwork({ protocolFamily: "svm" })).toBe(false);
});

it("lists open tasks using read-only query parameters", async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: jest.fn().mockResolvedValue('{"tasks":[]}'),
});

const provider = taskMarketActionProvider({ apiUrl: "https://api.taskmarket.test" });
const result = await provider.listTasks(wallet, {
mode: "bounty",
tags: ["open-source"],
minReward: "1",
deadlineHours: 24,
limit: 10,
});

expect(JSON.parse(result)).toEqual({ success: true, data: { tasks: [] } });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.taskmarket.test/api/tasks?status=open&limit=10&sort=deadline_asc&mode=bounty&tags=open-source&minReward=1&deadlineHours=24",
);
});

it("returns a clear error without spending when the API rejects a read", async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 503,
text: jest.fn().mockResolvedValue("service unavailable"),
});

const provider = taskMarketActionProvider();
const result = await provider.getTask(wallet, {
taskId: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
});

expect(JSON.parse(result)).toEqual({
success: false,
status: 503,
error: "service unavailable",
});
});

it("requires an authorization confirmation in the schema", () => {
const parsed = TaskMarketSubmitWorkSchema.safeParse({
taskId: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
fileName: "deliverable.md",
mimeType: "text/markdown",
content: "final work",
role: "final",
});

expect(parsed.success).toBe(false);
});

it("submits a signed text artifact without attempting an automatic payment", async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
text: jest
.fn()
.mockResolvedValue(
'{"uploadUrl":"https://uploads.taskmarket.test/artifact","artifactKey":"key-1"}',
),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
})
.mockResolvedValueOnce({
ok: true,
status: 200,
text: jest.fn().mockResolvedValue('{"submissionId":"sub-1"}'),
});

const provider = new TaskMarketActionProvider({ apiUrl: "https://api.taskmarket.test" });
const taskId = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const result = await provider.submitWork(wallet, {
taskId,
fileName: "deliverable.md",
mimeType: "text/markdown",
content: "final work",
role: "final",
confirmation: "User authorized submission for task " + taskId,
});

expect(JSON.parse(result)).toEqual({
success: true,
workerAddress: "0x1111111111111111111111111111111111111111",
submission: { submissionId: "sub-1" },
});
expect(wallet.signMessage).toHaveBeenNthCalledWith(1, `taskmarket:submit:${taskId}`);
expect(wallet.signMessage).toHaveBeenNthCalledWith(2, `taskmarket:submit:${taskId}:key-1`);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
`https://api.taskmarket.test/api/tasks/${taskId}/submissions/request-upload-url`,
expect.objectContaining({
method: "POST",
headers: { "content-type": "application/json" },
}),
);

expect(fetchMock.mock.calls[1][0].toString()).toBe("https://uploads.taskmarket.test/artifact");
expect(fetchMock.mock.calls[1][1]).toEqual(
expect.objectContaining({ method: "PUT", body: Buffer.from("final work", "utf8") }),
);

const request = fetchMock.mock.calls[2][1] as RequestInit;
const body = JSON.parse(String(request.body));
expect(body.workerAddress).toBe("0x1111111111111111111111111111111111111111");
expect(body.signature).toBe("0xsignature");
expect(body.artifacts[0]).toMatchObject({
artifactKey: "key-1",
fileName: "deliverable.md",
mimeType: "text/markdown",
role: "final",
sizeBytes: 10,
});
expect(body.artifacts[0].sha256Hash).toMatch(/^[0-9a-f]{64}$/);
expect(body.artifacts[0].keccak256Hash).toMatch(/^0x[a-f0-9]{64}$/);
expect(request.headers).toMatchObject({
"X-Taskmarket-Idempotency-Key": expect.any(String),
});
});

it("does not expose taskmarket actions on another network", async () => {
wallet.getNetwork = jest.fn(() => OTHER_NETWORK);
const provider = taskMarketActionProvider();
const result = await provider.listTasks(wallet, {
mode: "bounty",
tags: [],
minReward: undefined,
deadlineHours: undefined,
limit: 20,
});

expect(JSON.parse(result).error).toContain("Base mainnet");
expect(fetchMock).not.toHaveBeenCalled();
});

it("defaults list schema filters safely", () => {
const parsed = TaskMarketListTasksSchema.parse({});
expect(parsed).toEqual({
mode: "bounty",
tags: [],
minReward: undefined,
deadlineHours: undefined,
limit: 20,
});
});
});
Loading
Loading