diff --git a/typescript/.changeset/nice-ducks-search.md b/typescript/.changeset/nice-ducks-search.md
new file mode 100644
index 000000000..9e96af6ad
--- /dev/null
+++ b/typescript/.changeset/nice-ducks-search.md
@@ -0,0 +1,5 @@
+---
+"@coinbase/agentkit": minor
+---
+
+Added media upload support for Twitter and embeds support for Farcaster
diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md
index cd868369e..c62fb7d27 100644
--- a/typescript/agentkit/README.md
+++ b/typescript/agentkit/README.md
@@ -261,7 +261,7 @@ const agent = createReactAgent({
post_cast |
- Creates a new cast (message) on Farcaster with up to 280 characters. |
+ Creates a new cast (message) on Farcaster with up to 280 characters and support for up to 2 embedded URLs. |
@@ -362,6 +362,10 @@ const agent = createReactAgent({
post_tweet_reply |
Creates a reply to an existing tweet using the tweet's unique identifier. |
+
+ upload_media |
+ Uploads media (images, videos) to Twitter that can be attached to tweets. |
+
diff --git a/typescript/agentkit/src/action-providers/farcaster/README.md b/typescript/agentkit/src/action-providers/farcaster/README.md
index 440b992c8..e83a29db8 100644
--- a/typescript/agentkit/src/action-providers/farcaster/README.md
+++ b/typescript/agentkit/src/action-providers/farcaster/README.md
@@ -21,6 +21,9 @@ farcaster/
- `post_cast`: Create a new Farcaster post
+ - Supports text content up to 280 characters
+ - Supports up to 2 embedded URLs via the optional `embeds` parameter
+
## Adding New Actions
To add new Farcaster actions:
@@ -36,5 +39,6 @@ The Farcaster provider supports all EVM-compatible networks.
## Notes
- Requires a Neynar API key. Visit the [Neynar Dashboard](https://dev.neynar.com/) to get your key.
+- Embeds allow you to attach URLs to casts that will render as rich previews in the Farcaster client
For more information on the **Farcaster Protocol**, visit [Farcaster Documentation](https://docs.farcaster.xyz/).
diff --git a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts
index 6bc7be9d4..6e160cd4d 100644
--- a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts
+++ b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts
@@ -27,6 +27,41 @@ describe("Farcaster Action Provider Input Schemas", () => {
expect(result.data).toEqual(validInput);
});
+ it("should successfully parse valid cast text with embeds", () => {
+ const validInput = {
+ castText: "Hello, Farcaster!",
+ embeds: [{ url: "https://example.com" }],
+ };
+ const result = FarcasterPostCastSchema.safeParse(validInput);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual(validInput);
+ });
+
+ it("should fail when embed URL is invalid", () => {
+ const invalidInput = {
+ castText: "Hello, Farcaster!",
+ embeds: [{ url: "invalid-url" }],
+ };
+ const result = FarcasterPostCastSchema.safeParse(invalidInput);
+
+ expect(result.success).toBe(false);
+ });
+
+ it("should fail when there are more than 2 embeds", () => {
+ const invalidInput = {
+ castText: "Hello, Farcaster!",
+ embeds: [
+ { url: "https://example1.com" },
+ { url: "https://example2.com" },
+ { url: "https://example3.com" },
+ ],
+ };
+ const result = FarcasterPostCastSchema.safeParse(invalidInput);
+
+ expect(result.success).toBe(false);
+ });
+
it("should fail parsing cast text over 280 characters", () => {
const invalidInput = {
castText: "a".repeat(281),
@@ -123,6 +158,36 @@ describe("Farcaster Action Provider", () => {
body: JSON.stringify({
signer_uuid: mockConfig.signerUuid,
text: args.castText,
+ embeds: undefined,
+ }),
+ });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(result).toContain("Successfully posted cast to Farcaster");
+ expect(result).toContain(JSON.stringify(mockCastResponse));
+ });
+
+ it("should successfully post a cast with embeds", async () => {
+ mockFetch.mockResolvedValueOnce({
+ json: () => Promise.resolve(mockCastResponse),
+ });
+
+ const args = {
+ castText: "Hello, Farcaster!",
+ embeds: [{ url: "https://example.com" }],
+ };
+
+ const result = await actionProvider.postCast(args);
+
+ expect(mockFetch).toHaveBeenCalledWith("https://api.neynar.com/v2/farcaster/cast", {
+ method: "POST",
+ headers: {
+ api_key: mockConfig.neynarApiKey,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ signer_uuid: mockConfig.signerUuid,
+ text: args.castText,
+ embeds: args.embeds,
}),
});
expect(mockFetch).toHaveBeenCalledTimes(1);
diff --git a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts
index 9757291e1..f891aec30 100644
--- a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts
+++ b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts
@@ -111,6 +111,7 @@ A failure response will return a message with the Farcaster API request error:
name: "post_cast",
description: `
This tool will post a cast to Farcaster. The tool takes the text of the cast as input. Casts can be maximum 280 characters.
+Optionally, up to 2 embeds (links to websites or mini apps) can be attached to the cast by providing an array of URLs in the embeds parameter.
A successful response will return a message with the API response as a JSON payload:
{}
@@ -133,6 +134,7 @@ A failure response will return a message with the Farcaster API request error:
body: JSON.stringify({
signer_uuid: this.signerUuid,
text: args.castText,
+ embeds: args.embeds,
}),
});
const data = await response.json();
diff --git a/typescript/agentkit/src/action-providers/farcaster/schemas.ts b/typescript/agentkit/src/action-providers/farcaster/schemas.ts
index 2170ea598..2443ceebe 100644
--- a/typescript/agentkit/src/action-providers/farcaster/schemas.ts
+++ b/typescript/agentkit/src/action-providers/farcaster/schemas.ts
@@ -14,6 +14,14 @@ export const FarcasterAccountDetailsSchema = z
export const FarcasterPostCastSchema = z
.object({
castText: z.string().max(280, "Cast text must be a maximum of 280 characters."),
+ embeds: z
+ .array(
+ z.object({
+ url: z.string().url("Embed URL must be a valid URL"),
+ }),
+ )
+ .max(2, "Maximum of 2 embeds allowed")
+ .optional(),
})
.strip()
.describe("Input schema for posting a text-based cast");
diff --git a/typescript/agentkit/src/action-providers/twitter/README.md b/typescript/agentkit/src/action-providers/twitter/README.md
index fb5c84b1c..a27a34286 100644
--- a/typescript/agentkit/src/action-providers/twitter/README.md
+++ b/typescript/agentkit/src/action-providers/twitter/README.md
@@ -19,6 +19,7 @@ twitter/
- `account_mentions`: Get mentions for a specified Twitter (X) user
- `post_tweet`: Post a new tweet
- `post_tweet_reply`: Reply to a tweet
+- `upload_media`: Upload media files (images, videos) to Twitter
## Adding New Actions
diff --git a/typescript/agentkit/src/action-providers/twitter/schemas.ts b/typescript/agentkit/src/action-providers/twitter/schemas.ts
index 67309df9b..d6d3ec275 100644
--- a/typescript/agentkit/src/action-providers/twitter/schemas.ts
+++ b/typescript/agentkit/src/action-providers/twitter/schemas.ts
@@ -27,6 +27,11 @@ export const TwitterAccountMentionsSchema = z
export const TwitterPostTweetSchema = z
.object({
tweet: z.string().max(280, "Tweet must be a maximum of 280 characters."),
+ mediaIds: z
+ .array(z.string())
+ .max(4, "Maximum of 4 media IDs allowed")
+ .optional()
+ .describe("Optional array of 1-4 media IDs to attach to the tweet"),
})
.strip()
.describe("Input schema for posting a tweet");
@@ -40,6 +45,24 @@ export const TwitterPostTweetReplySchema = z
tweetReply: z
.string()
.max(280, "The reply to the tweet which must be a maximum of 280 characters."),
+ mediaIds: z
+ .array(z.string())
+ .max(4, "Maximum of 4 media IDs allowed")
+ .optional()
+ .describe("Optional array of 1-4 media IDs to attach to the tweet"),
})
.strip()
.describe("Input schema for posting a tweet reply");
+
+/**
+ * Input schema for uploading media.
+ */
+export const TwitterUploadMediaSchema = z
+ .object({
+ filePath: z
+ .string()
+ .min(1, "File path is required.")
+ .describe("The path to the media file to upload"),
+ })
+ .strip()
+ .describe("Input schema for uploading media to Twitter");
diff --git a/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.test.ts b/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.test.ts
index 87a14d9f1..7f8cfd9b6 100644
--- a/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.test.ts
+++ b/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.test.ts
@@ -1,4 +1,4 @@
-import { TwitterApi, TwitterApiv2 } from "twitter-api-v2";
+import { TwitterApi, TwitterApiv2, TwitterApiv1 } from "twitter-api-v2";
import { TwitterActionProvider } from "./twitterActionProvider";
import { TweetUserMentionTimelineV2Paginator } from "twitter-api-v2";
@@ -15,10 +15,13 @@ const MOCK_USERNAME = "CDPAgentkit";
const MOCK_TWEET = "Hello, world!";
const MOCK_TWEET_ID = "0123456789012345678";
const MOCK_TWEET_REPLY = "Hello again!";
+const MOCK_MEDIA_ID = "987654321";
+const MOCK_FILE_PATH = "/path/to/image.jpg";
describe("TwitterActionProvider", () => {
let mockClient: jest.Mocked;
let provider: TwitterActionProvider;
+ let mockUploadMedia: jest.Mock;
beforeEach(() => {
mockClient = {
@@ -27,7 +30,13 @@ describe("TwitterActionProvider", () => {
tweet: jest.fn(),
} as unknown as jest.Mocked;
+ mockUploadMedia = jest.fn();
+
jest.spyOn(TwitterApi.prototype, "v2", "get").mockReturnValue(mockClient);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ jest.spyOn(TwitterApi.prototype, "v1", "get").mockReturnValue({
+ uploadMedia: mockUploadMedia,
+ } as unknown as TwitterApiv1);
provider = new TwitterActionProvider(MOCK_CONFIG);
});
@@ -179,7 +188,18 @@ describe("TwitterActionProvider", () => {
it("should successfully post a tweet", async () => {
const response = await provider.postTweet({ tweet: MOCK_TWEET });
- expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET);
+ expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET, {});
+ expect(response).toContain("Successfully posted to Twitter");
+ expect(response).toContain(JSON.stringify(mockResponse));
+ });
+
+ it("should successfully post a tweet with media", async () => {
+ const mediaIds = [MOCK_MEDIA_ID] as [string];
+ const response = await provider.postTweet({ tweet: MOCK_TWEET, mediaIds });
+
+ expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET, {
+ media: { media_ids: mediaIds },
+ });
expect(response).toContain("Successfully posted to Twitter");
expect(response).toContain(JSON.stringify(mockResponse));
});
@@ -190,7 +210,7 @@ describe("TwitterActionProvider", () => {
const response = await provider.postTweet({ tweet: MOCK_TWEET });
- expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET);
+ expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET, {});
expect(response).toContain("Error posting to Twitter");
expect(response).toContain(error.message);
});
@@ -222,6 +242,22 @@ describe("TwitterActionProvider", () => {
expect(response).toContain(JSON.stringify(mockResponse));
});
+ it("should successfully post a tweet reply with media", async () => {
+ const mediaIds = [MOCK_MEDIA_ID] as [string];
+ const response = await provider.postTweetReply({
+ tweetId: MOCK_TWEET_ID,
+ tweetReply: MOCK_TWEET_REPLY,
+ mediaIds,
+ });
+
+ expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET_REPLY, {
+ reply: { in_reply_to_tweet_id: MOCK_TWEET_ID },
+ media: { media_ids: mediaIds },
+ });
+ expect(response).toContain("Successfully posted reply to Twitter");
+ expect(response).toContain(JSON.stringify(mockResponse));
+ });
+
it("should handle errors when posting a tweet reply", async () => {
const error = new Error("An error has occurred");
mockClient.tweet.mockRejectedValue(error);
@@ -239,6 +275,31 @@ describe("TwitterActionProvider", () => {
});
});
+ describe("Upload Media Action", () => {
+ beforeEach(() => {
+ mockUploadMedia.mockResolvedValue(MOCK_MEDIA_ID);
+ });
+
+ it("should successfully upload media", async () => {
+ const response = await provider.uploadMedia({ filePath: MOCK_FILE_PATH });
+
+ expect(mockUploadMedia).toHaveBeenCalledWith(MOCK_FILE_PATH);
+ expect(response).toContain("Successfully uploaded media to Twitter");
+ expect(response).toContain(MOCK_MEDIA_ID);
+ });
+
+ it("should handle errors when uploading media", async () => {
+ const error = new Error("Invalid file format");
+ mockUploadMedia.mockRejectedValue(error);
+
+ const response = await provider.uploadMedia({ filePath: MOCK_FILE_PATH });
+
+ expect(mockUploadMedia).toHaveBeenCalledWith(MOCK_FILE_PATH);
+ expect(response).toContain("Error uploading media to Twitter");
+ expect(response).toContain(error.message);
+ });
+ });
+
describe("Network Support", () => {
it("should always return true for network support", () => {
expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "1" })).toBe(true);
diff --git a/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.ts b/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.ts
index 76df6d2d9..f2334feab 100644
--- a/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.ts
+++ b/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.ts
@@ -8,6 +8,7 @@ import {
TwitterAccountMentionsSchema,
TwitterPostTweetSchema,
TwitterPostTweetReplySchema,
+ TwitterUploadMediaSchema,
} from "./schemas";
/**
@@ -143,6 +144,8 @@ A failure response will return a message with the Twitter API request error:
name: "post_tweet",
description: `
This tool will post a tweet on Twitter. The tool takes the text of the tweet as input. Tweets can be maximum 280 characters.
+Optionally, up to 4 media items (images, videos) can be attached to the tweet by providing their media IDs in the mediaIds array.
+Media IDs are obtained by first uploading the media using the upload_media action.
A successful response will return a message with the API response as a JSON payload:
{"data": {"text": "hello, world!", "id": "0123456789012345678", "edit_history_tweet_ids": ["0123456789012345678"]}}
@@ -153,10 +156,20 @@ A failure response will return a message with the Twitter API request error:
})
async postTweet(args: z.infer): Promise {
try {
- const response = await this.getClient().v2.tweet(args.tweet);
+ let mediaOptions = {};
+
+ if (args.mediaIds && args.mediaIds.length > 0) {
+ // Convert array to tuple format expected by the Twitter API
+ const mediaIdsTuple = args.mediaIds as unknown as [string, ...string[]];
+ mediaOptions = {
+ media: { media_ids: mediaIdsTuple },
+ };
+ }
+
+ const response = await this.getClient().v2.tweet(args.tweet, mediaOptions);
return `Successfully posted to Twitter:\n${JSON.stringify(response)}`;
} catch (error) {
- return `Error posting to Twitter:\n${error}`;
+ return `Error posting to Twitter:\n${error} with media ids: ${args.mediaIds}`;
}
}
@@ -169,7 +182,10 @@ A failure response will return a message with the Twitter API request error:
@CreateAction({
name: "post_tweet_reply",
description: `
-This tool will post a tweet on Twitter. The tool takes the text of the tweet as input. Tweets can be maximum 280 characters.
+This tool will post a reply to a tweet on Twitter. The tool takes the text of the reply and the ID of the tweet to reply to as input.
+Replies can be maximum 280 characters.
+Optionally, up to 4 media items (images, videos) can be attached to the reply by providing their media IDs in the mediaIds array.
+Media IDs can be obtained by first uploading the media using the upload_media action.
A successful response will return a message with the API response as a JSON payload:
{"data": {"text": "hello, world!", "id": "0123456789012345678", "edit_history_tweet_ids": ["0123456789012345678"]}}
@@ -180,9 +196,17 @@ A failure response will return a message with the Twitter API request error:
})
async postTweetReply(args: z.infer): Promise {
try {
- const response = await this.getClient().v2.tweet(args.tweetReply, {
+ const options: Record = {
reply: { in_reply_to_tweet_id: args.tweetId },
- });
+ };
+
+ if (args.mediaIds && args.mediaIds.length > 0) {
+ // Convert array to tuple format expected by the Twitter API
+ const mediaIdsTuple = args.mediaIds as unknown as [string, ...string[]];
+ options.media = { media_ids: mediaIdsTuple };
+ }
+
+ const response = await this.getClient().v2.tweet(args.tweetReply, options);
return `Successfully posted reply to Twitter:\n${JSON.stringify(response)}`;
} catch (error) {
@@ -190,6 +214,33 @@ A failure response will return a message with the Twitter API request error:
}
}
+ /**
+ * Upload media to Twitter.
+ *
+ * @param args - The arguments containing the file path
+ * @returns A JSON string containing the media ID or error message
+ */
+ @CreateAction({
+ name: "upload_media",
+ description: `
+This tool will upload media (images, videos, etc.) to Twitter.
+
+A successful response will return a message with the media ID:
+ Successfully uploaded media to Twitter: 1234567890
+
+A failure response will return a message with the Twitter API request error:
+ Error uploading media to Twitter: Invalid file format`,
+ schema: TwitterUploadMediaSchema,
+ })
+ async uploadMedia(args: z.infer): Promise {
+ try {
+ const mediaId = await this.getClient().v1.uploadMedia(args.filePath);
+ return `Successfully uploaded media to Twitter: ${mediaId}`;
+ } catch (error) {
+ return `Error uploading media to Twitter: ${error}`;
+ }
+ }
+
/**
* Checks if the Twitter action provider supports the given network.
* Twitter actions don't depend on blockchain networks, so always return true.