Back to blog

How to Send Casts in a Farcaster Frame

How to Send Casts in a Farcaster Frame

Justin Hunter

Have you ever seen the movie Inception? Of course you have. Everyone has. If you somehow haven’t, Inception is a story of traversing dreams, but not just one layer of dreams, it goes deep into dreams within dreams within dreams. The movie still lives near the top of many pop culture references and memes. So, you know we’re going to build something that not only leverages the meme, but does something awesome.

We’re going to build a Farcaster Frame that allows users to connect the frame to their Warpcast account (WHAT?!) and create a “signer,” which is sort of like authorizing another app (the frame in this case) to send casts and make updates on the user’s behalf. Once the user has authorized this, they will be able to type in the frame’s input box and send a cast. However, since this is Inception inspired, it wasn’t enough to cast within a client that lets you cast. We are also going to make every cast sent through the frame a quote cast of the frame the user is interacting with. It’s Castception.

Ok, I’m out of breath with that explanation. Let’s just build.

Getting Started

This tutorial is going to make use of Pinata’s Farcaster Auth APIs, among other things. So, you’re going to need a paid account. You can head over to Pinata and sign up for the Picnic Plan for $20 per month here.

You’re also going to need a free Cloudflare account. We’re going to build this frame using Cloudflare workers because we like technology and Cloudflare’s performance for frame-related activities has impressed us. You can sign up for that here.

Outside of that, you’ll just need a text editor, some general JavaScript and TypeScript knowledge, and a good attitude. Let’s fire up the old command line and get to work by starting with some boilerplate courtesy of the Cloudflare CLI. Run the following command:

npm create cloudflare@latest

You’ll be prompted to indicate the project path first. This must be an all lowercase path that will represent the name of your project. We can use castception as our name and path.

Next, you’ll be asked if you want to use TypeScript, and of course your answer is yes. Finally, you’ll be asked what type of project you want to create. We could go in a few different directions, but I chose the router project because it gives us the flexibility to have multiple API routes for our frame without the complexity of something like the full OpenAPI spec project.

Once you’ve chosen that and the command runs, you’ll be able to run the following command and change into your project directory:

cd castception

From there, you’ll need to install a couple of dependencies. Let’s run this command:

npm i pinata-fdk qr-image

The Pinata FDK is a versatile toolkit that will allow us to work with frames and the Farcaster API. The other dependency will be used for generating QR codes.

Now, with these things installed, we’re ready to start building. Let’s run through a quick plan of action.

  1. We need a frame that will let a user enter some text and click a send cast button, but the frame should also have a “Connect to Warpcast” button
  2. If a user sends a cast but has not approved a signer via Warpcast, we should let them know
  3. If a user clicks the “Connect to Warpcast” button, we should send a new frame with two buttons: “Check Status” and “Approve in Warpcast”
  4. Clicking “Check Status” will see if the signer has been approved using Pinata’s APIs, and “Approve in Warpcast” will redirect the user to a page with a link/QR code to approve the signer in the Warpcast mobile app
  5. When the signer has been approved in Warpcast, and the user clicks the “Check Status” button, they will see a frame with an input field again to send the cast
  6. When the user inputs text and clicks the “Send Cast” button, it will send the cast, as specified in the introduction

This feels relatively straightforward, but it will touch on many parts of the Farcaster API. Let’s write some code.

Building the Frame

The very first thing we need to do is make sure our Cloudflare worker can build and doesn’t throw errors because of theqr-image package. Open up your project files and find the wrangler.toml file in the root of the project. Add the following:

name = "castception"
main = "src/index.ts"
compatibility_date = "2023-04-08"
type = "webpack"
node_compat = true

This will ensure our package will work. Now, we can really write some code. Let’s start with the src/index.ts file. This will be our entry point and it will control everything else our frame does.

You can replace everything in that file with this:

import handleApprove from './approve';
import apiRouter from './router';
import { PinataFDK } from "pinata-fdk";

export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {		
        const fdk = new PinataFDK({
            pinata_jwt: env.PINATA_JWT,
            pinata_gateway: env.PINATA_GATEWAY
        });
        const url = new URL(request.url);

        switch (url.pathname) {
            case '/approve': 
                return handleApprove.fetch(request, env, ctx);
        }

        if (url.pathname.startsWith('/api/')) {
            return apiRouter.handle(request, env);
        }

        const frameMetadata = fdk.getFrameMetadata({
            post_url: `https://castception.pinatadrops.com/api/castception`,
            input: { text: "Send your cast" },
            aspect_ratio: "1:1",
            buttons: [
                { label: 'Send cast', action: 'post' },
                { label: 'Connect to Warpcast', action: "post" },
                { label: 'Learn more', action: "link", target: "https://pinata.cloud/blog"}
            ],
            cid: "Qmbs3NXdzcqopAuAkj9jCn271X9aNbMcVQjeQEARdsGhaQ",
        });
    
        const frameRes =
            `<!DOCTYPE html><html><head>
                            <title>Castception</title>
                            <meta property="fc:frame" content="vNext" />
                            <meta property="og:title" content="Castception" />
                            <meta property="og:description" content="Castception" />
                            ${frameMetadata}
                            </head><body>
                            <img style="width: 600px; height: 600px;" src="${env.PINATA_GATEWAY}/ipfs/Qmbs3NXdzcqopAuAkj9jCn271X9aNbMcVQjeQEARdsGhaQ" />
                            <a href="https://warpcast.com/~/channel/pinata">Follow Pinata on Warpcast</a>
                            </body></html>`;

        return new Response(frameRes,
            { headers: { 'Content-Type': 'text/html' } }
        );
    },
};

We’re going to have to make some changes to our file structure because we’re not using some of the files that came with the boilerplate and we’re adding a new one called approve.ts. We'll get to that in a moment. Let’s focus on what’s in this file.

We’re using the Pinata FDK to make creating our frame easy. We’re specifying a post_url that will point back to our API. You’ll need to change these values to match your project’s resources. When running locally, you can update the urls to point to a localhost environment.

The frame itself has three buttons and a text input. It also has an image represented by an IPFS CID. You should create your own image, upload it to Pinata, and use your own CID there.

This is all returned as an HTML file to the browser.

Now, before we write more code, delete the other files in the src directory besides index.ts and router.ts. Then, create a new file in that folder called approve.ts. We're going to fill out the approve.ts soon, but let’s move on to the router.ts first.

This file handles our API endpoints, so we can render frames from here that respond to different actions. Replace the contents of that file with:

import { Router } from 'itty-router';
import { PinataFDK } from "pinata-fdk";

const router = Router();

router.post('/api/castception', async (request, env) => {
    const fdk = new PinataFDK({
        pinata_jwt: env.PINATA_JWT,
        pinata_gateway: env.PINATA_GATEWAY
    },
    );

    const content = await request.json();

    const { isValid, message } = await fdk.validateFrameMessage(content);

    if (!isValid) {
        return new Response("Unauthorized");
    }

    if (!message) {
        return new Response("No message");
    }

    const buttonClicked = message.data?.frameActionBody?.buttonIndex

    //    Check if signer is connected and approved or fid
    const signerRes = await fetch(`https://api.pinata.cloud/v3/farcaster/signers?fid=${message?.data?.fid}`, {
        method: "GET",
        headers: {
            'Authorization': `Bearer ${env.PINATA_JWT}`
        }
    })
    const signerDetails: any = await signerRes.json()
    const signers = signerDetails.data.signers

    let alreadyApproved = false;
    let approved;
    if (signers.length > 0) {
        approved = signers.find((s: any) => s.signer_approved === true);        
        if (approved) {
            alreadyApproved = true;
        }
    }

    if (!alreadyApproved) {
        const fcRes = await fetch(`https://api.pinata.cloud/v3/farcaster/sponsored_signers`, {
            method: "POST",
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${env.PINATA_JWT}`
            },
            body: JSON.stringify({
                app_fid: 4823
            })
        })
        const data: any = await fcRes.json()

        const frameMetadata = fdk.getFrameMetadata({
            post_url: `https://castception.pinatadrops.com/api/castception/status?redirectUrl=${data.data.deep_link_url}&token=${data.data.token}`,
            aspect_ratio: "1:1",
            buttons: [
                { label: 'Check approval status', action: 'post' },
                { label: 'Approve in Warpcast', action: "link", target: `https://castception.pinatadrops.com/approve?redirectUrl=${data.data.deep_link_url}` }, 
                { label: 'Learn more', action: "link", target: "https://pinata.cloud/blog"}
            ],
            state: { token: data.data.token },
            cid: "Qmcp2GUFfJfNxZu9T5H7CFBeVKLzv4uSqwnBPhj8ANFPoE",
        });

        const frameRes =
            `<!DOCTYPE html>
        <html>
            <head>
                <title>Castception</title>
                <meta property="fc:frame" content="vNext" />
                <meta property="og:title" content="Castception" />
                <meta property="og:description" content="Castception" />
                ${frameMetadata}
            </head>
            <body>
                <img style="width: 600px; height: 600px;" src="${env.PINATA_GATEWAY}/ipfs/Qmbs3NXdzcqopAuAkj9jCn271X9aNbMcVQjeQEARdsGhaQ" />
            </body>
        </html>`;

        return new Response(frameRes, { headers: { 'Content-Type': 'text/html' } })
    } 

    if (buttonClicked === 1 && message.data?.frameActionBody?.inputText) {    
        //    Get username
        const usernameRes = await fetch(`https://api.pinata.cloud/v3/farcaster/users/${message?.data?.fid}`, {
            headers: {
                'Authorization': `Bearer ${env.PINATA_JWT}`
            }
        })    
        const user: any = await usernameRes.json()
        const username = user?.data?.username

        //    Send cast
        const signerId = approved?.signer_uuid
        const castRes = await fetch(`https://api.pinata.cloud/v3/farcaster/casts`, {
            method: "POST", 
            headers: {
                'Content-Type': 'application/json', 
                'Authorization': `Bearer ${env.PINATA_JWT}`
            }, 
            body: JSON.stringify({
                castAddBody: {
                    embeds: [
                        {
                            castId: {
                                fid: content?.untrustedData?.castId?.fid, 
                                hash: content?.untrustedData?.castId?.hash
                            }                            
                        }, {
                            url: "https://castception.pinatadrops.com/api/castception"
                        }
                    ], 
                    text: content?.untrustedData?.inputText
                }, 
                signerId
            })
        })

        const cast: any = await castRes.json();

        const warpcastUrl = `https://warpcast.com/${username}/${cast?.data?.hash.slice(0, 10)}`
        const frameMetadata = fdk.getFrameMetadata({
            post_url: `https://castception.pinatadrops.com/api/castception`,
            aspect_ratio: "1:1",
            buttons: [
                { label: 'View cast', action: 'link', target: warpcastUrl },
                { label: 'Learn more', action: "link", target: "https://pinata.cloud/blog"}
            ],
            cid: "QmUq5kHXuZXkvUohVLTuzBw7kKT8RWMVV16X78mpFDyQLi",
        });

        const frameRes =
            `<!DOCTYPE html><html><head>
                                <title>Castception</title>
                                <meta property="fc:frame" content="vNext" />
                                <meta property="og:title" content="Castception" />
                                <meta property="og:description" content="Castception" />
                                ${frameMetadata}
                                </head><body><img style="width: 600px; height: 600px;" src="${env.PINATA_GATEWAY}/ipfs/Qmbs3NXdzcqopAuAkj9jCn271X9aNbMcVQjeQEARdsGhaQ" /></body></html>`;
        return new Response(frameRes, { headers: { 'Content-Type': 'text/html' } })
    } else {
        const frameMetadata = fdk.getFrameMetadata({
            post_url: `https://castception.pinatadrops.com/api/castception`,
            input: { text: "Send your cast" },
            aspect_ratio: "1:1",
            buttons: [
                { label: 'Send cast', action: 'post' },
                { label: 'Connect with Warpcast', action: "post" },
                { label: 'Learn more', action: "link", target: "https://pinata.cloud/blog"}
            ],
            cid: "QmNMTDjXisTyWERGCipzg1ieViLgbdnji8FYjkNxpwckVz",
        });

        const frameRes =
            `<!DOCTYPE html><html><head>
                                <title>Castception</title>
                                <meta property="fc:frame" content="vNext" />
                                <meta property="og:title" content="Castception" />
                                <meta property="og:description" content="Castception" />
                                ${frameMetadata}
                                </head><body><img style="width: 600px; height: 600px;" src="${env.PINATA_GATEWAY}/ipfs/Qmbs3NXdzcqopAuAkj9jCn271X9aNbMcVQjeQEARdsGhaQ" /></body></html>`;
        return new Response(frameRes, { headers: { 'Content-Type': 'text/html' } })

    }
});

This is our first and longest endpoint. If you remember, this maps to the post_url included in the frame metadata in the index.ts file. The reason this endpoint has so much code is because it’s putting in work.

This endpoint checks to see if the user has already registered a signer with Warpcast. If not, it kicks off the process by using Pinata’s Sponsored Signer API to generate a signer and creates a redirect link so that the user can approve the signer in Warpcast.

If the user already has a signer registered and approved, we move on to sending the cast. This is making use of another important Farcaster API from Pinata. If you have a user who has registered a signer already, you just need the signer_id - a safe representation of the data necessary to send casts on the user’s behalf. Let’s look at the code to send a cast in detail:

const castRes = await fetch(`https://api.pinata.cloud/v3/farcaster/casts`, {
			method: "POST", 
			headers: {
				'Content-Type': 'application/json', 
				'Authorization': `Bearer ${env.PINATA_JWT}`
			}, 
			body: JSON.stringify({
				castAddBody: {
					embeds: [
						{
							castId: {
								fid: content?.untrustedData?.castId?.fid, 
								hash: content?.untrustedData?.castId?.hash
							}							
						}, {
							url: "https://castception.pinatadrops.com/api/castception"
						}
					], 
					text: content?.untrustedData?.inputText
				}, 
				signerId
			})
		})

		const cast: any = await castRes.json();

Here, we are not just adding the text of the cast to what we send, we are creating two embeds. Embeds in Farcaster are essentially links. One embed is the original cast that the user interacted with. This means we are “quote-casting”. The other embed is a link to the frame itself. So the user’s quote cast will have their text entered in the text input, the frame link, and the quoted cast they originally interacted with.

Castception!

To return the Warpcast URL for the frame to use as a button redirect, we make use of yet another Pinata API:

const usernameRes = await fetch(`https://api.pinata.cloud/v3/farcaster/users/${message?.data?.fid}`, {
	headers: {
		'Authorization': `Bearer ${env.PINATA_JWT}`
	}
})	
const user: any = await usernameRes.json()
const username = user?.data?.username

We need the user’s username to construct the Warpcast URL, but we only have the FID. So we look up the username by FID using this API. Then, we can build our Warpcast URL:

const warpcastUrl = `https://warpcast.com/${username}/${cast?.data?.hash.slice(0, 10)}`

It sounds complex, but the Pinata API makes it really simple, as you can see in the code above.

Finally, we return the appropriate frame depending on the criteria: Either a frame to check the status of the approval of the signer or a frame to go and see the cast.

But, we have more to do in this file. If you look at the frame metadata, you can see we have another post_url we've created, and we also have a redirect link we created (this will be what we handle in our approve.ts file).

Let’s finish up the router.ts file. Add the following below your existing endpoint:

router.post('/api/castception/status', async (request, env) => {
    const fdk = new PinataFDK({
        pinata_jwt: env.PINATA_JWT,
        pinata_gateway: env.PINATA_GATEWAY
    });

    const url = new URL(request.url);
    const redirectUrl = url.searchParams.get('redirectUrl');
    const token = url.searchParams.get('token');

    const content = await request.json();
    const { isValid, message } = await fdk.validateFrameMessage(content);

    if (!isValid) {
        return new Response("Unauthorized");
    }

    if (!message) {
        return new Response("No message");
    }

    const buttonClicked = message.data?.frameActionBody?.buttonIndex
    if (buttonClicked === 1) {
        const approvalResponse = await fetch(`https://api.pinata.cloud/v3/farcaster/poll_warpcast_signer?token=${token}`, {
            method: "POST",
            headers: {
                'Authorization': `Bearer ${env.PINATA_JWT}`
            },
            body: null
        })

        const status: any = await approvalResponse.json()

        if (status?.data?.result?.signedKeyRequest?.state === "completed") {
            const frameMetadata = fdk.getFrameMetadata({
                post_url: `https://castception.pinatadrops.com/api/castception`,
                input: { text: "Send your cast" },
                aspect_ratio: "1:1",
                buttons: [
                    { label: 'Send cast', action: 'post' },
                    { label: 'Learn more', action: "link", target: "https://pinata.cloud/blog"}
                ],
                cid: "QmNMTDjXisTyWERGCipzg1ieViLgbdnji8FYjkNxpwckVz",
            });

            const frameRes =
                `<!DOCTYPE html><html><head>
                                <title>Castception</title>
                                <meta property="fc:frame" content="vNext" />
                                <meta property="og:title" content="Castception" />
                                <meta property="og:description" content="Castception" />
                                ${frameMetadata}
                                </head><body><img style="width: 600px; height: 600px;" src="${env.PINATA_GATEWAY}/ipfs/Qmbs3NXdzcqopAuAkj9jCn271X9aNbMcVQjeQEARdsGhaQ" /></body></html>`;
            return new Response(frameRes, { headers: { 'Content-Type': 'text/html' } })
        } else {
            const frameMetadata = fdk.getFrameMetadata({
                post_url: `https://castception.pinatadrops.com/api/castception/status?redirectUrl=${redirectUrl}&token=${token}`,
                aspect_ratio: "1:1",
                buttons: [
                    { label: 'Check approval status', action: 'post' },
                    { label: 'Approve in Warpcast', action: "link", target: `https://castception.pinatadrops.com/approve?redirectUrl=${redirectUrl}` }
                ],
                state: { token: message?.data?.frameActionBody?.state },
                cid: "QmXE632TCnfSfxyfTcLNZRJ7265rw1iXpJQxAvfA2yG1hD",
            });

            const frameRes =
                `<!DOCTYPE html>
            <html>
                <head>
                    <title>Castception</title>
                    <meta property="fc:frame" content="vNext" />
                    <meta property="og:title" content="Castception" />
                    <meta property="og:description" content="Castception" />
                    ${frameMetadata}
                </head>
                <body>
                    <img style="width: 600px; height: 600px;" src="${env.PINATA_GATEWAY}/ipfs/Qmbs3NXdzcqopAuAkj9jCn271X9aNbMcVQjeQEARdsGhaQ" />
                </body>
            </html>`;

            return new Response(frameRes, { headers: { 'Content-Type': 'text/html' } })
        }
    }

    return new Response("");
});

// 404 for everything else
router.all('*', () => new Response('Not Found.', { status: 404 }));

export default router;

The rest of this file handles checking the status of the signer approval. We’re using query parameters for state but we could have also used the Frame spec’s built-in state property. This endpoint will conditionally respond with either a new send cast frame or another frame to continue checking the status of the signer’s approval.

At the bottom of the file, we handle all other routes with a 404 response and we export the route for use in our index.ts file.

Now, we have just one thing left to do: the approve.ts file. Open that file up. It should be empty since you just created it. Add the following:

import qr from 'qr-image'

async function generateQRCode(request: any) {
    const { text } = await request.json()
    const headers = { "Content-Type": "image/png" }
    const qr_png = qr.imageSync(text || "https://workers.dev")

    return new Response(qr_png, { headers });
}

export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
        if (request.method === 'POST') {
            return generateQRCode(request)
        }
        const url = new URL(request.url);
        const redirectUrl = url.searchParams.get('redirectUrl'); // get a query param value (?redirectUrl=...)

        if (!redirectUrl) {
            return new Response('Bad request: Missing `redirectUrl` query param', { status: 400 });
        }

        return new Response(`
        <!DOCTYPE html>
        <html>
            <head>
                <title>Castception</title>
                <meta property="fc:frame" content="vNext" />
                <meta property="og:title" content="Castception" />
                <meta property="og:description" content="Castception" />
            </head>
            <body style="min-width: 100vw; min-height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center;">
            <h1>Castception</h1>
            <p>Use the QR code below if on desktop. Otherwise, click the link.</p>      
            <img id="qr" src="#" />
            <p>If on mobile, click this link to approve in Waprcast</p>
            <a href="${redirectUrl}">Approve in Warpcast</a>
            <script>      
                document.addEventListener("DOMContentLoaded", function() {
                    console.log("Loaded")
                    
                    function generate() {
                        console.log("Generating")
                        fetch("https://castception.pinata-marketing-enterprise.workers.dev/approve", {
                            method: "POST",
                            headers: { "Content-Type": "application/json" },
                            body: JSON.stringify({ text: "${redirectUrl}" })
                        })
                        .then(response => response.blob())
                        .then(blob => {
                            const reader = a new FileReader();
                            reader.onloadend = function () {
                                document.querySelector("#qr").src = reader.result; // Update the image source with the newly generated QR code
                            }
                            reader.readAsDataURL(blob);
                        })
                    }     
                    generate()
                });
            </script>
            </body>
        `, {
            headers: {
                "Content-Type": "text/html"
            }
        })
    },
};

Let’s talk about the purpose of this file and then walk through the code. When approving a signer in Warpcast, the user must use the mobile app. Using Pinata’s API, we generate a deep link URL that will allow the user to approve the signer in their Warpcast app. However, we don’t know from the frame if the user is on mobile or desktop. So, we send them to a page that lets them either click a link (if on mobile) or scan a QR code if on desktop. This file handles rendering that page with the appropriate data.

At the top of the file, you can see we’re using the qr-image package. The first function simply generates the image, but how does that function get called? If you move down to our exported function, then you can see we have a check for POST requests. If the request method is a POST, then we generate the QR code image using data passed into the request body (i.e. the deep link URL).

To get that deep link URL to the browser, we handle the GET request by reading the original query parameters tied to the URL where we’re rendering the page. This includes the deep link URL. So we pass that info into our HTML. Our HTML has a script that will automatically make a POST request to this same endpoint and generate the QR code on the screen.

Again, you’ll need to update URLs to match your own deployed code or localhost environment.

With this file complete, we’re almost ready to test. Let’s add our env variables.

Create a .dev.vars file and add the following:

PINATA_JWT=YOUR PINATA JWT
PINATA_GATEWAY=YOUR PINATA GATEWAY

For deployments you’ll also want to add non-secret variables to your wrangler.toml file like this:

PINATA_GATEWAY = "YOUR PINATA GATEWAY WRAPPED IN QUOTES"

When you deploy, you’ll need to use the Cloudflare workers dashboard to add your secret variables (in this case the PINATA_JWT).

Now, we can test. If you’re running locally, you can use the following command:

npx wrangler dev

If you’re ready to deploy, you can run the following command:

npx wrangler deploy

To test the frame, I recommend using Warpcast’s frame debugger tool. But testing in production is always fun too, so send a cast with your deployed URL, if you want. Just kidding, you should test it.

Wrapping Up

This is a fun frame highlighting the functionality of Pinata’s Warpcast APIs and Pinata’s Farcaster Auth APIs. You can imagine how you might extend the same use of these APIs to build full-blown Farcaster clients, bots, and pretty much anything else that you can imagine.

If you want to see the full source for this project, it’s here.

Happy Casting!

Subscribe to paid plan image

Share this post:

Stay up to date

Join our newsletter for the latest stories & product updates from the Pinata community.