Building an Open Floor Parrot Agent

Community Article Published July 18, 2025

In this short guide, we will build a simple parrot agent together. The parrot agent will simply repeat everything you send him and a small ๐Ÿฆœ emoji in front of the return. We will create the Open Floor Protocol-compliant agent with the help of the @openfloor/protocol package.

Initial Setup

First, let's set up our project by creating the project folder and installing the required packages:

mkdir parrot-agent
cd parrot-agent
npm init -y
npm install express @openfloor/protocol
npm  install -D typescript @types/node @types/express ts-node

We will also need a TypeScript configuration file, so create tsconfig.json and add the following content:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Now that the basic set up is done, let us start coding together!

Step 1: Building the Parrot Agent Class

Before we create our parrot agent class, let's create a new folder src where we will store all of our files.

Create a new file src/parrot-agent.ts, this will contain the main logic of our agent.

Step 1.1: Add the imports

Lets start with the import of everything we need from the @openfloor/protocol package, add them at the top of your parrot-agent.ts file:

import { 
  BotAgent, 
  ManifestOptions, 
  UtteranceEvent, 
  Envelope,
  createTextUtterance,
  isUtteranceEvent
} from '@openfloor/protocol';

Why these imports?

  • BotAgent - The base class we'll extend
  • ManifestOptions - To define our agent's capabilities
  • UtteranceEvent - The type of event we'll handle
  • Envelope - Container for Open Floor messages
  • createTextUtterance - Helper to create text responses
  • isUtteranceEvent - To check if an event is an utterance

Step 1.2: Start the ParrotAgent class

Now let's start creating our ParrotAgent class by extending the BotAgent:

/**
 * ParrotAgent - A simple agent that echoes back whatever it receives
 * Extends BotAgent to provide parrot functionality
 */
export class ParrotAgent extends BotAgent {
  constructor(manifest: ManifestOptions) {
    super(manifest);
  }

What we just did:

  • Created a class that extends BotAgent
  • Added a constructor that takes a manifest and passes it to the parent class
  • The manifest will define what our agent can do

Step 1.3: Override the processEnvelope method

The processEnvelope method of the BotAgent class is the main entry point for agent message processing. So, this is where the magic happens:

  /**
   * Override the processEnvelope method to handle parrot functionality
   */
  async processEnvelope(incomingEnvelope: Envelope): Promise<Envelope> {

Now let's build the method body step by step. First, create an array to store our responses:

    const responseEvents: any[] = [];

Next, we loop through each event in the incoming envelope:

    for (const event of incomingEnvelope.events) {

We also should check if this event is meant for us. So, add this inside the loop:

      // Check if this event is addressed to us
      const addressedToMe = !event.to || 
        event.to.speakerUri === this.speakerUri || 
        event.to.serviceUrl === this.serviceUrl;

Why this check?

  • !event.to - If no recipient is specified, it's for everyone.
  • event.to.speakerUri === this.speakerUri - Direct message to us
  • event.to.serviceUrl === this.serviceUrl - Message to our service

With this check, we know the event is really meant for us, and we can now handle the two types of events we care about:

      if (addressedToMe && isUtteranceEvent(event)) {
        const responseEvent = await this._handleParrotUtterance(event, incomingEnvelope);
        if (responseEvent) responseEvents.push(responseEvent);
      } else if (addressedToMe && event.eventType === 'getManifests') {
        // We respond to the getManifests event with the publishManifest event
        responseEvents.push({
          eventType: 'publishManifest',
          // We use the senders speakerUri as the recipient
          to: { speakerUri: incomingEnvelope.sender.speakerUri },
          parameters: {
            servicingManifests: [this.manifest.toObject()]
          }
        });
      }

What's happening here:

  • If it's a text message (utterance), we'll handle it with our parrot logic
  • If someone asks for our capabilities via the getManifests event, we send back our manifest

To finish the method, we can now close the loop and return an envelope as a response with all the required response events:

    }

    // Create response envelope with all response events
    return new Envelope({
      schema: { version: incomingEnvelope.schema.version },
      conversation: { id: incomingEnvelope.conversation.id },
      sender: {
        speakerUri: this.speakerUri,
        serviceUrl: this.serviceUrl
      },
      events: responseEvents
    });
  }

Step 1.4: Implement the parrot logic

You saw in the processEnvelope method that we call a yet undefined _handleParrotUtterance, this is the private method we will now implement to echo back what we got sent via the utterance event:

  /**
   * Handle utterance events by echoing them back
   */
  private async _handleParrotUtterance(
    event: UtteranceEvent, 
    incomingEnvelope: Envelope
  ): Promise<any> {
    try {

First, let's try to extract the dialog event from the utterance:

      const dialogEvent = event.parameters?.dialogEvent as { features?: any };
      if (!dialogEvent || typeof dialogEvent !== 'object' || !dialogEvent.features || typeof dialogEvent.features !== 'object') {
        return createTextUtterance({
          speakerUri: this.speakerUri,
          text: "๐Ÿฆœ *chirp* I didn't receive a valid dialog event!",
          to: { speakerUri: incomingEnvelope.sender.speakerUri }
        });
      }

What we're doing:

  • Extracting the dialog event from the utterance parameters
  • Checking if it has the structure we expect
  • If not, sending a friendly error message

Now, as we know we are dealing with a valid dialog event, we can try to get the text from it:

      const textFeature = dialogEvent.features.text;
      if (!textFeature || !textFeature.tokens || textFeature.tokens.length === 0) {
        // No text to parrot, send a default response
        return createTextUtterance({
          speakerUri: this.speakerUri,
          text: "๐Ÿฆœ *chirp* I can only repeat text messages!",
          to: { speakerUri: incomingEnvelope.sender.speakerUri }
        });
      }

We only handle text, so as you see also here, we would return early with an createTextUtterance and a generic message if the textFeature is not how we expect it.

But now everything should be valid, and we can go for the actual parroting:

      // Combine all token values to get the full text
      const originalText = textFeature.tokens
        .map((token: any) => token.value)
        .join('');

      // Create parrot response with emoji prefix
      const parrotText = `๐Ÿฆœ ${originalText}`;
      
      return createTextUtterance({
        speakerUri: this.speakerUri,
        text: parrotText,
        to: { speakerUri: incomingEnvelope.sender.speakerUri },
        confidence: 1.0 // Parrot is very confident in repeating!
      });

The parroting logic:

  • Extract text from tokens by mapping over them and joining
  • Add the ๐Ÿฆœ emoji prefix
  • Create a text utterance response
  • Set confidence to 1.0 because ๐Ÿฆœ are confident!

Finally, we can add some error handling and close the method:

    } catch (error) {
      console.error('Error in parrot utterance handling:', error);
      // Send error response
      return createTextUtterance({
        speakerUri: this.speakerUri,
        text: "๐Ÿฆœ *confused chirp* Something went wrong while trying to repeat that!",
        to: { speakerUri: incomingEnvelope.sender.speakerUri }
      });
    }
  }

The only thing that is left to do is close the class with a closing brace:

}

Step 1.5: Add the factory function

After the class, add this factory function with the default configuration:

/**
 * Factory function to create a ParrotAgent with default configuration
 */
export function createParrotAgent(options: {
  speakerUri: string;
  serviceUrl: string;
  name?: string;
  organization?: string;
  description?: string;
}): ParrotAgent {
  const {
    speakerUri,
    serviceUrl,
    name = 'Parrot Agent',
    organization = 'OpenFloor Demo',
    description = 'A simple parrot agent that echoes back messages with a ๐Ÿฆœ emoji'
  } = options;

  const manifest: ManifestOptions = {
    identification: {
      speakerUri,
      serviceUrl,
      organization,
      conversationalName: name,
      synopsis: description
    },
    capabilities: [
      {
        keyphrases: ['echo', 'repeat', 'parrot', 'say'],
        descriptions: [
          'Echoes back any text message with a ๐Ÿฆœ emoji',
          'Repeats user input verbatim',
          'Simple text mirroring functionality'
        ]
      }
    ]
  };

  return new ParrotAgent(manifest);
}

What this factory does:

  • Takes configuration options
  • Provides some defaults
  • Creates a manifest that describes our agent's capabilities
  • Returns a new ParrotAgent instance

Step 2: Building the Express Server

The agent itself is done, but how to talk to it? We need to build our express server for this, so start with creating a src/server.ts file.

Step 2.1: Add imports

Add these imports at the top:

import express, { Request, Response } from 'express';
import { createParrotAgent } from './parrot-agent';
import { 
  validateAndParsePayload
} from '@openfloor/protocol';

Step 2.2: Create the Express app

const app = express();
app.use(express.json());

Step 2.3: Add CORS middleware

You might want to add a CORS configuration to allow access to your agent from different origins:

// CORS middleware for http://127.0.0.1:4000
const allowedOrigin = 'http://127.0.0.1:4000';
app.use((req, res, next) => {
  if (req.headers.origin === allowedOrigin) {
    res.header('Access-Control-Allow-Origin', allowedOrigin);
    res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
    res.header('Access-Control-Allow-Headers', 'Content-Type');
  }
  if (req.method === 'OPTIONS') {
    return res.sendStatus(200);
  }
  next();
});

Why this CORS setup?

  • Only allows requests from the specific domain
  • Handles preflight OPTIONS requests
  • Restricts to POST methods and the Content-Type header

Step 2.4: Create the agent instance

Now we need to create our parrot by using the factory function createParrotAgent. Important is that the serviceUrl matches your server endpoint; otherwise our agent will deny the request (remember the check we added in section 1.3).

// Create the parrot agent instance
const parrotAgent = createParrotAgent({
  speakerUri: 'tag:openfloor-demo.com,2025:parrot-agent',
  serviceUrl: process.env.SERVICE_URL || 'http://localhost:8080/',
  name: 'Polly the Parrot',
  organization: 'OpenFloor Demo Corp',
  description: 'A friendly parrot that repeats everything you say!'
});

Step 2.5: Build the main endpoint step by step

Now we have the agent and the Express app, but the most important part is still missing, and that's our endpoint:

// Main Open Floor Protocol endpoint
app.post('/', async (req: Request, res: Response) => {
  try {
    console.log('Received request:', JSON.stringify(req.body, null, 2));

First, let's validate the incoming payload with the validateAndParsePayload function from the @openfloor/protocol package:

    // Validate and parse the incoming payload
    const validationResult = validateAndParsePayload(JSON.stringify(req.body));
    
    if (!validationResult.valid) {
      console.error('Validation errors:', validationResult.errors);
      return res.status(400).json({
        error: 'Invalid OpenFloor payload',
        details: validationResult.errors
      });
    }

Now we know the payload is valid, and we can extract the envelope:

    const payload = validationResult.payload!;
    const incomingEnvelope = payload.openFloor;

    console.log('Processing envelope from:', incomingEnvelope.sender.speakerUri);

Then let's process the envelope through our parrot agent:

    // Process the envelope through the parrot agent
    const outgoingEnvelope = await parrotAgent.processEnvelope(incomingEnvelope);

After the processing, we can create and send the response:

    // Create response payload
    const responsePayload = outgoingEnvelope.toPayload();
    const response = responsePayload.toObject();

    console.log('Sending response:', JSON.stringify(response, null, 2));

    res.json(response);

Finally, we add the catch block and close the endpoint:

  } catch (error) {
    console.error('Error processing request:', error);
    res.status(500).json({
      error: 'Internal server error',
      message: error instanceof Error ? error.message : 'Unknown error'
    });
  }
});

Step 2.6: Export the app

export default app;

Step 3: Creating the Entry Point

We end by creating a simple src/index.ts as our entry point:

import app from './server';

const PORT = process.env.PORT || 8080;

app.listen(PORT, () => {
  console.log(`Parrot Agent server running on port ${PORT}`);
});

Step 4: Final Setup

Add or overwrite these scripts in the existing scripts object in your package.json:

{
  "scripts": {
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts",
    "build": "tsc"
  }
}

Test Your Implementation

Run this to test:

npm run dev

Send your manifest or utterance requests to http://localhost:8080/ to see if it's working! You can also download the simple single HTML file manifest and utterance chat azettl/openfloor-js-chat to test your agent locally.

If you found this guide useful follow me for more and let me know what you build with it in the comments!

Community

Sign up or log in to comment