This guide walks through creating a complete FlowApp from scratch. We’ll build a Cal.com integration as our example.
Project structure
FlowApps are located in IqraInfrastructure/Managers/FlowApp/Apps/:
Apps/
└── CalCom/
├── CalComApp.cs # Main app definition
├── Actions/
│ ├── BookMeetingAction.cs # Action implementation
│ └── BookMeeting.json # JSON Schema for action inputs
├── Fetchers/
│ └── GetEventTypesByIdFetcher.cs # Dynamic dropdown data
└── Models/
└── CalComModels.cs # Request/response DTOs
Step 1: Create the main app class
Create a class that implements IFlowApp:
IqraInfrastructure/Managers/FlowApp/Apps/CalCom/CalComApp.cs
using IqraCore . Interfaces . FlowApp ;
using IqraCore . Interfaces . Integration ;
using Microsoft . Extensions . Logging ;
namespace IqraInfrastructure . Managers . FlowApp . Apps . CalCom
{
public class CalComApp : IFlowApp
{
public string AppKey => "cal_com" ;
public string Name => "Cal.com" ;
public string IconUrl => "https://cal.com/favicon.ico" ;
public string ? IntegrationType => "cal_com" ; // Links to admin dashboard
private readonly IHttpClientFactory _httpClientFactory ;
private readonly ILogger < CalComApp > _logger ;
private const string BaseUrl = "https://api.cal.com/" ;
public IReadOnlyList < IFlowAction > Actions { get ; }
public IReadOnlyList < IFlowDataFetcher > DataFetchers { get ; }
public CalComApp ( IHttpClientFactory httpClientFactory , ILogger < CalComApp > logger )
{
_httpClientFactory = httpClientFactory ;
_logger = logger ;
// Initialize actions
Actions = new List < IFlowAction >
{
new BookMeetingAction ( this ),
new CancelBookingAction ( this ),
// ... more actions
};
// Initialize fetchers
DataFetchers = new List < IFlowDataFetcher >
{
new GetEventTypesByIdFetcher ( this )
};
}
public HttpClient CreateClient ()
{
var client = _httpClientFactory . CreateClient ();
client . BaseAddress = new Uri ( BaseUrl );
return client ;
}
}
}
The AppKey must be unique across all FlowApps. Use lowercase snake_case by convention.
Constructor dependencies
You can inject any registered service:
IHttpClientFactory - For making HTTP requests
ILogger<T> - For logging
Custom services from your DI container
Step 2: Define an action
Actions perform the actual work. Create a partial class implementing IFlowAction:
IqraInfrastructure/Managers/FlowApp/Apps/CalCom/Actions/BookMeetingAction.cs
using IqraCore . Entities . FlowApp ;
using IqraCore . Interfaces . FlowApp ;
using IqraCore . Models . FlowApp . Integration ;
using System . Text . Json ;
namespace IqraInfrastructure . Managers . FlowApp . Apps . CalCom . Actions
{
public partial class BookMeetingAction : IFlowAction
{
private readonly CalComApp _app ;
public BookMeetingAction ( CalComApp app )
{
_app = app ;
}
public string ActionKey => "BookMeeting" ;
public string Name => "Book a Meeting" ;
public string Description => "Schedules a new booking on Cal.com." ;
public IReadOnlyList < ActionOutputPort > GetOutputPorts ()
{
return new List < ActionOutputPort >
{
new ActionOutputPort { Key = "success" , Label = "Success (201)" },
new ActionOutputPort { Key = "conflict" , Label = "Slot Taken (409)" },
new ActionOutputPort { Key = "error" , Label = "Error" }
};
}
public async Task < ActionExecutionResult > ExecuteAsync (
JsonElement input ,
BusinessAppIntegrationDecryptedModel integration )
{
try
{
// 1. Extract credentials
var apiKey = integration . DecryptedFields [ "ApiKey" ];
var client = _app . CreateClient ();
// 2. Build request from validated input
var request = new CreateBookingRequest
{
Start = input . GetProperty ( "start" ). GetString (),
Attendee = new Attendee
{
Name = input . GetProperty ( "attendeeName" ). GetString (),
Email = input . GetProperty ( "attendeeEmail" ). GetString (),
TimeZone = input . TryGetProperty ( "attendeeTimeZone" , out var tz )
? tz . GetString () ?? "UTC"
: "UTC"
}
};
// 3. Make API call
var httpRequest = new HttpRequestMessage ( HttpMethod . Post , "/v2/bookings" );
httpRequest . Content = new StringContent (
JsonSerializer . Serialize ( request ),
System . Text . Encoding . UTF8 ,
"application/json"
);
httpRequest . Headers . Add ( "Authorization" , $"Bearer { apiKey } " );
httpRequest . Headers . Add ( "cal-api-version" , "2024-08-13" );
var response = await client . SendAsync ( httpRequest );
var responseContent = await response . Content . ReadAsStringAsync ();
// 4. Handle different outcomes
if ( response . IsSuccessStatusCode )
{
var result = JsonSerializer . Deserialize < CalComResponse < BookingResponseData >>( responseContent );
return ActionExecutionResult . SuccessPort ( "success" , result ? . Data );
}
if ( response . StatusCode == HttpStatusCode . Conflict )
{
return ActionExecutionResult . SuccessPort (
"conflict" ,
new { message = "Time slot is no longer available." }
);
}
return ActionExecutionResult . Failure (
"API_ERROR" ,
$"Cal.com Error: { response . StatusCode } - { responseContent } "
);
}
catch ( Exception ex )
{
return ActionExecutionResult . Failure ( "EXCEPTION" , ex . Message );
}
}
}
}
Always mark action classes as partial - the GetInputSchemaJson() method is auto-generated by the FlowAppSchemaGenerator.
Output ports
Output ports define the possible execution paths:
IqraInfrastructure/Managers/FlowApp/Apps/CalCom/Actions/BookMeetingAction.cs:24-32
public IReadOnlyList < ActionOutputPort > GetOutputPorts ()
{
return new List < ActionOutputPort >
{
new ActionOutputPort { Key = "success" , Label = "Success (201)" },
new ActionOutputPort { Key = "conflict" , Label = "Slot Taken (409)" },
new ActionOutputPort { Key = "error" , Label = "Error" }
};
}
success : API call succeeded
conflict : Specific error case (slot unavailable)
error : Generic failure (handled automatically by ActionExecutionResult.Failure)
Step 3: Create the JSON schema
Create a schema file with the same base name as your action (e.g., BookMeetingAction.cs → BookMeeting.json):
IqraInfrastructure/Managers/FlowApp/Apps/CalCom/Actions/BookMeeting.json
{
"type" : "object" ,
"title" : "Book a Meeting" ,
"required" : [ "start" , "attendeeName" , "attendeeEmail" ],
"properties" : {
"start" : {
"type" : "string" ,
"title" : "Start Time" ,
"description" : "ISO 8601 Date string (e.g. 2024-01-01T10:00:00Z)"
},
"attendeeName" : {
"type" : "string" ,
"title" : "Attendee Name"
},
"attendeeEmail" : {
"type" : "string" ,
"title" : "Attendee Email"
},
"attendeeTimeZone" : {
"type" : "string" ,
"title" : "Attendee TimeZone" ,
"default" : "UTC"
},
"notes" : {
"type" : "string" ,
"title" : "Additional Notes"
}
},
"oneOf" : [
{
"title" : "By Event ID" ,
"required" : [ "eventTypeId" ],
"properties" : {
"eventTypeId" : {
"type" : "integer" ,
"title" : "Event Type ID" ,
"x-fetcher" : "GetEventTypesById"
}
}
},
{
"title" : "By User & Slug" ,
"required" : [ "username" , "eventTypeSlug" ],
"properties" : {
"username" : {
"type" : "string" ,
"title" : "Username"
},
"eventTypeSlug" : {
"type" : "string" ,
"title" : "Event Type Slug"
}
}
}
]
}
The x-fetcher property links a field to a data fetcher for dynamic dropdowns. See Schema Definition for details.
Step 4: Create a data fetcher
Data fetchers populate dynamic dropdowns:
IqraInfrastructure/Managers/FlowApp/Apps/CalCom/Fetchers/GetEventTypesByIdFetcher.cs
using IqraCore . Entities . FlowApp ;
using IqraCore . Interfaces . FlowApp ;
using IqraCore . Models . FlowApp . Integration ;
using System . Net . Http . Json ;
using System . Text . Json ;
namespace IqraInfrastructure . Managers . FlowApp . Apps . CalCom . Fetchers
{
public class GetEventTypesByIdFetcher : IFlowDataFetcher
{
private readonly CalComApp _app ;
public GetEventTypesByIdFetcher ( CalComApp app ) { _app = app ; }
public string FetcherKey => "GetEventTypesById" ;
public async Task < List < DynamicOption >> FetchOptionsAsync (
BusinessAppIntegrationDecryptedModel ? integration ,
JsonElement context )
{
if ( integration == null ) return new ();
try
{
var apiKey = integration . DecryptedFields [ "ApiKey" ];
var client = _app . CreateClient ();
var httpRequest = new HttpRequestMessage ( HttpMethod . Get , "/v2/event-types" );
httpRequest . Headers . Add ( "Authorization" , $"Bearer { apiKey } " );
httpRequest . Headers . Add ( "cal-api-version" , "2024-06-14" );
var response = await client . SendAsync ( httpRequest );
if ( ! response . IsSuccessStatusCode ) return new ();
var result = await response . Content . ReadFromJsonAsync < CalComResponse < List < EventTypeDto >>>();
return result ? . Data ? . Select ( e => new DynamicOption
{
Label = $" { e . Title } ( { e . Length } m)" ,
Value = e . Id ,
Description = $"ID: { e . Id } "
}). ToList () ?? new ();
}
catch { return new (); }
}
}
}
The context parameter contains the current form state, allowing dependent fetchers:
// Fetch event types only for the selected username
if ( context . TryGetProperty ( "username" , out var username ))
{
var user = username . GetString ();
// Fetch event types for specific user
}
Step 5: Build and test
Build the project
The FlowAppSchemaGenerator runs at build time and generates the GetInputSchemaJson() method: public partial class BookMeetingAction
{
public string GetInputSchemaJson ()
{
return "{ \" type \" : \" object \" , \" title \" : \" Book a Meeting \" ,...}" ;
}
}
Verify discovery
Check the logs on startup: If your app isn’t discovered, verify:
Class implements IFlowApp
Class is not abstract or an interface
Class is in the same assembly as FlowAppManager
Test in the UI
Configure credentials in the admin dashboard
Create a new agent script
Add your FlowApp action to the flow
Fill in the form and test execution
Public apps (no authentication)
For apps that don’t require credentials:
public class UtilityApp : IFlowApp
{
public string AppKey => "utility" ;
public string Name => "Utility Functions" ;
public string IconUrl => "/icons/utility.svg" ;
public string ? IntegrationType => null ; // No integration required
// ...
}
Set RequiresIntegration = false in your actions:
public class GetCountriesAction : IFlowAction
{
public bool RequiresIntegration => false ;
public async Task < ActionExecutionResult > ExecuteAsync (
JsonElement input ,
BusinessAppIntegrationDecryptedModel ? integration )
{
// integration will be null
// Fetch data from public API
}
}
Best practices
Use descriptive action and fetcher keys
Good: BookMeeting, GetEventTypes, SendEmail
Bad: Action1, Fetch, DoStuff
Keys appear in logs and error messages.
Define meaningful output ports
Use specific ports for common error cases: new ActionOutputPort { Key = "conflict" , Label = "Slot Already Booked" },
new ActionOutputPort { Key = "not_found" , Label = "Event Type Not Found" },
new ActionOutputPort { Key = "rate_limit" , Label = "Rate Limit Exceeded" }
This allows agents to handle errors gracefully.
Return structured data in results
Include relevant data in the result for downstream nodes: return ActionExecutionResult . SuccessPort ( "success" , new {
bookingId = result . Id ,
confirmationUrl = result . Url ,
startTime = result . StartTime
});
Handle rate limits and retries
Implement retry logic with exponential backoff: if ( response . StatusCode == HttpStatusCode . TooManyRequests )
{
var retryAfter = response . Headers . RetryAfter ? . Delta ?? TimeSpan . FromSeconds ( 60 );
return ActionExecutionResult . Failure (
"RATE_LIMIT" ,
$"Rate limited. Retry after { retryAfter . TotalSeconds } s"
);
}
Use structured logging: _logger . LogError (
ex ,
"Failed to book meeting for {Email} at {StartTime}" ,
attendeeEmail ,
startTime
);
Next steps
Schema definition Learn advanced schema features like conditional fields, dynamic fetchers, and custom validation