In this guide we will be building a Farcaster frame for local development that runs a Lilysay prompt on the Lilypad Network.
Users of the frame can input a prompt and a generate an image. The generated image will appear in the box above the input and will allow a user to view the ASCII art as an image.
This is an example for running a local version as inspiration for developers. The Lilypad CLI will need to be wrapped and the project hosted as an API to run in production and be used in a Farcaster Frame.
We will need to fund a wallet with Lilypad and Arbitrum Sepolia testnet tokens. Follow the first 2 sections labelled "Setting up MetaMask" and "Funding your wallet" from our Quick Start docs.
Add .env.local
Add the following into your .env.local file. The private key is used to run the CLI jobs. Make sure that your .env.local file is added to the .gitignore file as your private key should not be exposed or pushed.
Your WEB3_PRIVATE_KEY can be retrieved from the MetaMask account details menu. For more info, check out the official guide from MetaMask on how to get a your private key. Please do not push your private key to GitHub.
Framegear is a simple tool provided by the @coinbase/onchainkit package that allows you to run and test your frames locally without publishing the frame.
We will be using Framegear for this build. However, there are other libraries that can be used for this project instead of Framegear.
In a separate terminal, clone down the onchainkit repo and run Framegear:
git clone https://github.com/coinbase/onchainkit.gitcd onchainkit/framegearnpm inpm run dev
Navigate to http://localhost:1337 and keep that window open for when we start to write the frame.
Configuration for frame
We will need to set up the metadata for our Next.js application that includes Farcaster frame information. It will configure the elements and a URL for frame posting, while also specifying Open Graph metadata for improved social sharing.
In app/page.tsx, add the following before the Home function declaration:
The UI elements for this frame are all rendered in the app/api/route.ts file, which acts as a request handler for different routes or endpoints within the web application. It defines the logic for handling user input, generating responses, and managing application state. The main functions include processing user prompts, handling status checks, and generating images asynchronously.
Routes
Hereβs how the routes are structured for this frame:
/api/frame?action=input: This route displays the initial user interface, which includes a text input field for the prompt and a button to submit the form. The user interface also updates dynamically based on the processing status, such as showing a placeholder image or the final generated image.
/api/frame?action=submit: This route processes the user input. When a prompt is submitted, the server initiates the image generation process asynchronously. While the image is being generated, the user sees a loading state, and they can check the progress.
/api/frame?action=check: This route checks the status of the image generation process. It updates the frame with either a completed image, an error message if the generation fails, or the processing state if the image is still being generated.
We also include a fallback just in case an error occurs during the processing of job.
/api/frame?action=save: Though not explicitly included, this could be an additional route for handling the logic of saving the generated image to a location for future access.
generateImage
The generateImage function handles the user input and generates the final image for display by utilizing the functions in app/services/cli.ts. It ensures that the image is generated asynchronously and the result is available for display in the frame, or handled properly in case of any errors during the generation process.
Frame images
Throughout the interaction process, various images are used. These images serve as visual cues during each step of the frame, such as when the user is prompted for input, while the image is being processed and once the final result is ready or if an error occurs.
This is where the execution of the Lilysay job happens. It will run the job, wait for it to finish and then create an image from the return value.
Inside of the app directory, create a new directory named services and inside of that create a file named cli.ts. The functions inside this file will allow us to send a prompt to the Lilypad Network using the user prompt and a predefined command that runs asynchronously in the terminal. Once the command is executed, Lilypad processes the input through its Lilysay module and outputs the results in the form of an ASCII image, which is then converted into a displayable image using an SVG-to-PNG transformation.
Here are the 3 functions inside this file:
createImageBufferFromAscii: Converts ASCII text into an SVG image and then uses the sharp library to convert the SVG into a PNG image buffer. This allows the display or saving of an image representation of the ASCII text.
runCliCommand: Executes a Lilypad CLI command to process the user's input text, captures the command's output, and converts it into an image buffer. It handles the entire process of running the command, capturing the output, and managing errors.
extractStdoutFilePath: Parses the CLI command's stdout to extract the file path where the Lilypad CLI has saved the output. It uses a regex pattern to identify the path in the command's output.
The following code snippet demonstrates the process:
import { spawn } from'child_process';import { promises as fs } from'fs';import*as path from'path';import { fileURLToPath } from'url';import sharp from'sharp';const__filename=fileURLToPath(import.meta.url);const__dirname=path.dirname(__filename);constMODULE_VERSION="cowsay:v0.0.4";// Function to generate an image buffer from ASCII textasyncfunctioncreateImageBufferFromAscii(asciiText, aspectRatio =1.91) {constfontSize=14;constlineHeight= fontSize +6;constpadding=20;constlines=asciiText.split('\\n');consttextHeight=lines.length* lineHeight + padding *2;let width, height;if (aspectRatio ===1.91) { width =Math.max(textHeight * aspectRatio,800); height = textHeight; } elseif (aspectRatio ===1) { width = height =Math.max(textHeight,800); }constescapeXML= (unsafe) => {returnunsafe.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'"); };constescapedLines=lines.map(line =>escapeXML(line));consttextWidth=Math.max(...escapedLines.map(line =>line.length)) * fontSize *0.6;constxPosition=Math.max((width - textWidth) /2,10);// Generate SVG markup with the ASCII contentconstsvgImage=` <svg width="${width}" height="${height}" xmlns="<http://www.w3.org/2000/svg>"> <rect width="100%" height="100%" fill="white" /> <style> .text-content { font-family: monospace; font-size: ${fontSize}px; fill: black; white-space: pre; } </style> <text x="${xPosition}" y="${padding}" class="text-content"> ${escapedLines.map((line, index) => `<tspan x="${xPosition}" dy="${index === 0 ? '0' : lineHeight}">${line}</tspan>`).join('')}
</text> </svg> `;// Convert the SVG to a PNG image buffer using sharpreturnsharp(Buffer.from(svgImage)).png().toBuffer();}// Function to run the Lilypad CLI commandexportasyncfunctionrunCliCommand(inputs) {console.log("Lilypad Starting...");constweb3PrivateKey=process.env.WEB3_PRIVATE_KEY;if (!web3PrivateKey) {thrownewError('WEB3_PRIVATE_KEY is not set in the environment variables.'); }// Construct the command to run Lilypad with the user inputconstcommand=`lilypad run ${MODULE_VERSION} -i Message="${inputs}"`;console.log("Command to be executed:", command);// Execute the command as a shell processreturnnewPromise((resolve, reject) => {constchild=spawn('bash', ['-c',`export WEB3_PRIVATE_KEY=${web3PrivateKey} && ${command}`]);let stdoutData ='';let stderrData ='';// Capture stdout from the CLI commandchild.stdout.on('data', (data) => { stdoutData +=data.toString();console.log(`Stdout: ${data}`); });child.stderr.on('data', (data) => { stderrData +=data.toString();console.error(`Stderr: ${data}`); });child.on('close',async (code) => {if (code !==0) {reject(newError(`Process exited with code ${code}`));return; }if (stderrData) {reject(newError(stderrData));return; }console.log("Process completed successfully!");try {// Extracts the file path, reads the ASCII content and converts it to an image bufferconststdoutFilePath=extractStdoutFilePath(stdoutData);constasciiContent=awaitfs.readFile(stdoutFilePath,'utf-8');constimageBuffer=awaitcreateImageBufferFromAscii(asciiContent);resolve(imageBuffer); } catch (error) {reject(newError(`Error processing output: ${error.message}`)); } });child.on('error', (error) => {reject(newError(`Error with spawning process: ${error.message}`)); }); });}// Helper function to extract the stdout file path from the CLI outputfunctionextractStdoutFilePath(stdoutData) {constmatch=stdoutData.match(/cat (\/tmp\/lilypad\/data\/downloaded-files\/\w+\/stdout)/);if (!match ||!match[1]) {thrownewError('Stdout file path not found in CLI output'); }return match[1];}
Sending the Request: The user's input text is passed directly into the Lilypad CLI command using a shell process. The input text is embedded within the command's arguments and executed asynchronously in the terminal.
Handling the Response: After the CLI command completes, the output is captured and processed. The response includes the file path to the generated ASCII image, which is then read from the file system and converted into a PNG image for further use.
Error Handling: If an error occurs during the execution of the CLI command or file processing, it is logged to the console, and the process is terminated with appropriate error messaging.
Testing
The Framegear server is running. Next, run your local server in your frame project. We will need to make sure it is running on port 3000:
npmrundev
Navigate to the Framegear host http://localhost:1337 and you will see an input labeled "Enter your frame URL". Add http://localhost:3000 to that and click "Fetch".
You should now see your frame and be able to interact with it. Enter a prompt to display in the Lilysay ACSII image!
Rendering results
As the job is processed, a βCheck Statusβ button will be displayed. Clicking this will check if the job has been completed. Once a job is completed, the results will be displayed in the frame. If there is an issue along the way, an error message will be displayed and the user will be prompted to try again.
Potential Use Cases
Running Lilysay jobs is just one of the ways you can utilize Lilypad in a frame, but you can run jobs with any available modules on the Lilypad Network. Some of these include: