A web framework for building applications where resources are the primary abstraction, invalid states are structurally impossible, and the application itself is the API documentation. Frank uses F# computation expressions as a declarative, extensible layer over ASP.NET Core.
Frank is built on four ideas:
Resources, not routes. HTTP resources are the unit of design. You define what a resource is and what it can do — the framework handles routing, method dispatch, and metadata. This is REST as Fielding described it, not the "REST" that became a synonym for JSON-over-HTTP.
Make invalid states impossible. Statechart-enforced state machines govern resource behavior at the framework level. If a transition isn't legal, it isn't available — in the response headers, in the HTML controls, in the API surface. No defensive coding required.
Built for the age of agents. Frank provides CLI tooling and extension libraries that layer semantic metadata onto your application — ALPS profiles, Link headers, JSON Home documents, OWL ontologies. Developers and agents can reflect on a running application, understand its capabilities, and refine it continuously.
Discovery is a first-class concern. A Frank application is understandable from a cold start. JSON Home documents advertise available resources. Link headers connect them. Allow headers declare what's possible in the current state. ALPS profiles define what things mean. Semantic web vocabularies give structure a shared language. No SDK, no out-of-band documentation — the application explains itself through standard HTTP, content negotiation, and open standards that clients (human or machine) can navigate without prior knowledge.
let home =
resource "/" {
name "Home"
get (fun (ctx: HttpContext) ->
ctx.Response.WriteAsync("Welcome!"))
}
[<EntryPoint>]
let main args =
webHost args {
useDefaults
resource home
}
0When you combine statecharts, affordances, and discovery, a Frank application tells clients exactly what's possible at every point in a protocol. Here's a TicTacToe game wired with affordance middleware:
webHost args {
useDefaults
plug resolveStateKey // 1. Resolve current state from store
useAffordances gameAffordanceMap // 2. Inject Link + Allow headers per state
useStatecharts // 3. Dispatch to state-specific handlers
resource gameResource // GET /games/{gameId}, POST /games/{gameId}
resource sseResource // GET /games/{gameId}/sse (Datastar SSE)
}When an agent hits /games/42 during X's turn, it gets back:
Allow: GET, POST
Link: </games/42>; rel="self", </games/42>; rel="makeMove"; method="POST"
Link: </alps/games>; rel="profile"
When the game is won, the response changes:
Allow: GET
Link: </games/42>; rel="self"
Link: </alps/games>; rel="profile"
No special client library. No out-of-band documentation. The API tells you what's possible right now, and the framework guarantees the response is correct.
Frank was inspired by @filipw's Building Microservices with ASP.NET Core (without MVC).
Frank (core)
│ ETag / conditional request middleware
│
├── Frank.Resources.Model ────── (zero dependencies)
│ └── Resource types, affordance map, runtime projections
│
├── Frank.Auth
│
├── Frank.LinkedData ──────────── Frank
│
├── Frank.OpenApi ─────────────── Frank
│
├── Frank.Datastar ────────────── Frank
│
├── Frank.Statecharts.Core ────── (zero dependencies)
│ └── Shared statechart AST (StatechartDocument, StateNode, TransitionEdge, Annotation, ParseResult)
│
├── Frank.Statecharts ─────────── Frank + Frank.Resources.Model + Frank.Statecharts.Core
│ └── WSD, ALPS, SCXML, smcat, XState parsers/generators
│ └── Cross-format validation pipeline
│ └── Affordance middleware (Link + Allow headers per state)
│ └── Profile discovery (/.well-known/frank-profiles, ALPS/OWL/SHACL/JSON Schema endpoints)
│
├── Frank.Discovery ───────────── Frank
│ └── HTTP-level discovery: OPTIONS/Allow headers, RFC 8288 Link headers
│
├── Frank.Validation ──────────── Frank.LinkedData + Frank.Auth
│
├── Frank.Provenance ──────────── Frank.LinkedData + Frank.Statecharts
│
└── Frank.Sparql (planned) ────── Frank.LinkedData + Frank.Provenance
WebHostBuilder- computation expression for configuringWebHostResourceBuilder- computation expression for configuring resources (routing)- No pre-defined view engine - use your preferred view engine implementation, e.g. Falco.Markup, Oxpecker.ViewEngine, or Hox
- Easy extensibility - just extend the
Builderwith your own methods!
module Program
open System.IO
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Routing
open Microsoft.AspNetCore.Routing.Internal
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open Frank
open Frank.Builder
let home =
resource "/" {
name "Home"
get (fun (ctx:HttpContext) ->
ctx.Response.WriteAsync("Welcome!"))
}
[<EntryPoint>]
let main args =
webHost args {
useDefaults
logging (fun options-> options.AddConsole().AddDebug())
plugWhen isDevelopment DeveloperExceptionPageExtensions.UseDeveloperExceptionPage
plugWhenNot isDevelopment HstsBuilderExtensions.UseHsts
plugBeforeRouting HttpsPolicyBuilderExtensions.UseHttpsRedirection
plugBeforeRouting StaticFileExtensions.UseStaticFiles
resource home
}
0Frank provides two middleware operations with different positions in the ASP.NET Core pipeline:
Request → plugBeforeRouting → UseRouting → plug → Endpoints → Response
Use for middleware that must run before routing decisions are made:
- HttpsRedirection - redirect before routing
- StaticFiles - serve static files without routing overhead
- ResponseCompression - compress all responses
- ResponseCaching - cache before routing
webHost args {
plugBeforeRouting HttpsPolicyBuilderExtensions.UseHttpsRedirection
plugBeforeRouting StaticFileExtensions.UseStaticFiles
resource myResource
}Use for middleware that needs routing information (e.g., the matched endpoint):
- Authentication - may need endpoint metadata
- Authorization - requires endpoint to check policies
- CORS - may use endpoint-specific policies
webHost args {
plug AuthenticationBuilderExtensions.UseAuthentication
plug AuthorizationAppBuilderExtensions.UseAuthorization
resource protectedResource
}Both plugWhen and plugWhenNot run in the plug position (after routing):
webHost args {
plugWhen isDevelopment DeveloperExceptionPageExtensions.UseDeveloperExceptionPage
plugWhenNot isDevelopment HstsBuilderExtensions.UseHsts
resource myResource
}Both plugBeforeRoutingWhen and plugBeforeRoutingWhenNot run in the plugBeforeRouting position (before routing):
let isDevelopment (app: IApplicationBuilder) =
app.ApplicationServices
.GetService<IWebHostEnvironment>()
.IsDevelopment()
webHost args {
// Only redirect to HTTPS in production
plugBeforeRoutingWhenNot isDevelopment HttpsPolicyBuilderExtensions.UseHttpsRedirection
// Only serve static files locally in development (CDN in production)
plugBeforeRoutingWhen isDevelopment StaticFileExtensions.UseStaticFiles
resource myResource
}Frank.Auth provides resource-level authorization for Frank applications, integrating with ASP.NET Core's built-in authorization infrastructure.
dotnet add package Frank.AuthAdd authorization requirements directly to resource definitions:
open Frank.Builder
open Frank.Auth
// Require any authenticated user
let dashboard =
resource "/dashboard" {
name "Dashboard"
requireAuth
get (fun ctx -> ctx.Response.WriteAsync("Welcome to Dashboard"))
}
// Require a specific claim
let adminPanel =
resource "/admin" {
name "Admin"
requireClaim "role" "admin"
get (fun ctx -> ctx.Response.WriteAsync("Admin Panel"))
}
// Require a role
let engineering =
resource "/engineering" {
name "Engineering"
requireRole "Engineering"
get (fun ctx -> ctx.Response.WriteAsync("Engineering Portal"))
}
// Reference a named policy
let reports =
resource "/reports" {
name "Reports"
requirePolicy "CanViewReports"
get (fun ctx -> ctx.Response.WriteAsync("Reports"))
}
// Compose requirements (AND semantics — all must pass)
let sensitive =
resource "/api/sensitive" {
name "Sensitive"
requireAuth
requireClaim "scope" "admin"
requireRole "Engineering"
get (fun ctx -> ctx.Response.WriteAsync("Sensitive data"))
}Configure authentication and authorization services using Frank's builder syntax:
[<EntryPoint>]
let main args =
webHost args {
useDefaults
useAuthentication (fun auth ->
// Configure your authentication scheme here
auth)
useAuthorization
authorizationPolicy "CanViewReports" (fun policy ->
policy.RequireClaim("scope", "reports:read") |> ignore)
resource dashboard
resource adminPanel
resource reports
}
0| Pattern | Operation | Behavior |
|---|---|---|
| Authenticated user | requireAuth |
401 if unauthenticated, 200 if authenticated |
| Claim (single value) | requireClaim "type" "value" |
403 if claim missing or wrong value |
| Claim (multiple values) | requireClaim "type" ["a"; "b"] |
200 if user has any listed value (OR) |
| Role | requireRole "Admin" |
403 if user not in role |
| Named policy | requirePolicy "PolicyName" |
Delegates to registered policy |
| Multiple requirements | Stack multiple require* |
AND semantics — all must pass |
| No requirements | (default) | Publicly accessible, zero overhead |
Frank.OpenApi provides native OpenAPI document generation for Frank applications, with first-class support for F# types and declarative metadata using computation expressions.
dotnet add package Frank.OpenApiDefine handlers with embedded OpenAPI metadata using the handler computation expression:
open Frank.Builder
open Frank.OpenApi
type Product = { Name: string; Price: decimal }
type CreateProductRequest = { Name: string; Price: decimal }
let createProductHandler =
handler {
name "createProduct"
summary "Create a new product"
description "Creates a new product in the catalog"
tags [ "Products"; "Admin" ]
produces typeof<Product> 201
accepts typeof<CreateProductRequest>
handle (fun (ctx: HttpContext) -> task {
let! request = ctx.Request.ReadFromJsonAsync<CreateProductRequest>()
let product = { Name = request.Name; Price = request.Price }
ctx.Response.StatusCode <- 201
do! ctx.Response.WriteAsJsonAsync(product)
})
}
let productsResource =
resource "/products" {
name "Products"
post createProductHandler
}| Operation | Description |
|---|---|
name "operationId" |
Sets the OpenAPI operationId |
summary "text" |
Brief summary of the operation |
description "text" |
Detailed description |
tags [ "Tag1"; "Tag2" ] |
Categorize endpoints |
produces typeof<T> statusCode |
Define response type and status code |
produces typeof<T> statusCode ["content/type"] |
Response with content negotiation |
producesEmpty statusCode |
Empty responses (204, 404, etc.) |
accepts typeof<T> |
Define request body type |
accepts typeof<T> ["content/type"] |
Request with content negotiation |
handle (fun ctx -> ...) |
Handler function (supports Task, Task<'a>, Async, Async<'a>) |
Frank.OpenApi automatically generates JSON schemas for F# types:
// F# records with required and optional fields
type User = {
Id: Guid
Name: string
Email: string option // Becomes nullable in schema
}
// Discriminated unions (anyOf/oneOf)
type Response =
| Success of data: string
| Error of code: int * message: string
// Collections
type Products = {
Items: Product list
Tags: Set<string>
Metadata: Map<string, string>
}Enable OpenAPI document generation in your application:
[<EntryPoint>]
let main args =
webHost args {
useDefaults
useOpenApi // Adds /openapi/v1.json endpoint
resource productsResource
}
0The OpenAPI document will be available at /openapi/v1.json.
Define multiple content types for requests and responses:
handler {
name "getProduct"
produces typeof<Product> 200 [ "application/json"; "application/xml" ]
accepts typeof<ProductQuery> [ "application/json"; "application/xml" ]
handle (fun ctx -> task { (* ... *) })
}Frank.OpenApi is fully backward compatible with existing Frank applications. You can:
- Mix
HandlerDefinitionand plainRequestDelegatehandlers in the same resource - Add OpenAPI metadata incrementally without changing existing code
- Use the library only where you need API documentation
Frank.LinkedData provides automatic RDF content negotiation for Frank applications. Endpoints marked with linkedData can serve JSON-LD, Turtle, and RDF/XML representations alongside standard JSON — driven by an OWL ontology extracted from your F# domain types.
dotnet add package Frank.LinkedData
dotnet add package Frank.Cli.MSBuildThe Frank.Cli.MSBuild package auto-embeds semantic artifacts (ontology, SHACL shapes, manifest) into your assembly at build time.
Add linkedData to any resource to enable RDF content negotiation:
open Frank.Builder
open Frank.LinkedData
let products =
resource "/products" {
name "Products"
linkedData
get (fun ctx -> ctx.Response.WriteAsJsonAsync(getAllProducts()))
}[<EntryPoint>]
let main args =
webHost args {
useDefaults
useLinkedData // Loads embedded ontology and enables content negotiation
resource products
}
0Clients request RDF formats via the Accept header:
| Accept Header | Response Format |
|---|---|
application/ld+json |
JSON-LD |
text/turtle |
Turtle |
application/rdf+xml |
RDF/XML |
application/json (or any other) |
Original JSON (pass-through) |
Use frank to extract an ontology from your F# types:
dotnet tool install --global frank
frank semantic extract --project MyApp.fsproj --base-uri https://example.org/api
frank semantic validate --project MyApp.fsproj
frank semantic compile --project MyApp.fsprojThe compiled artifacts are automatically embedded by Frank.Cli.MSBuild and loaded at startup by useLinkedData.
The CLI also provides unified commands for the full pipeline (semantic + statechart extraction):
frank extract --project MyApp.fsproj --base-uri https://example.org/api
frank generate --project MyApp.fsproj --format all --output ./specs
frank status --project MyApp.fsprojSee Spec Pipeline for the full CLI reference.
Frank.Datastar provides seamless integration with Datastar, enabling reactive hypermedia applications using Server-Sent Events (SSE).
Version 7.1.0 features a native SSE implementation with zero external dependencies, delivering high-performance Server-Sent Events directly via ASP.NET Core's IBufferWriter<byte> API. Supports .NET 8.0, 9.0, and 10.0.
dotnet add package Frank.Datastaropen Frank.Builder
open Frank.Datastar
let updates =
resource "/updates" {
name "Updates"
datastar (fun ctx -> task {
// SSE stream starts automatically
do! Datastar.patchElements "<div id='status'>Loading...</div>" ctx
do! Task.Delay(500)
do! Datastar.patchElements "<div id='status'>Complete!</div>" ctx
})
}
// With explicit HTTP method
let submit =
resource "/submit" {
name "Submit"
datastar HttpMethods.Post (fun ctx -> task {
let! signals = Datastar.tryReadSignals<FormData> ctx
match signals with
| ValueSome data ->
do! Datastar.patchElements $"<div id='result'>Received: {data.Name}</div>" ctx
| ValueNone ->
do! Datastar.patchElements "<div id='error'>Invalid data</div>" ctx
})
}Datastar.patchElements- Update HTML elements in the DOMDatastar.patchSignals- Update client-side signalsDatastar.removeElement- Remove elements by CSS selectorDatastar.executeScript- Execute JavaScript on the clientDatastar.tryReadSignals<'T>- Read and deserialize signals from request
Each operation also has a WithOptions variant for advanced customization.
Frank.Analyzers provides compile-time static analysis to catch common mistakes in Frank applications.
dotnet add package Frank.AnalyzersDetects when multiple handlers for the same HTTP method are defined on a single resource. Only the last handler would be used at runtime, so this is almost always a mistake.
// This will produce a warning:
resource "/example" {
name "Example"
get (fun ctx -> ctx.Response.WriteAsync("First")) // Warning: FRANK001
get (fun ctx -> ctx.Response.WriteAsync("Second")) // This one takes effect
}Frank.Analyzers works with:
- Ionide (VS Code)
- Visual Studio with F# support
- JetBrains Rider
Warnings appear inline as you type, helping catch issues before you even compile.
Make sure the following requirements are installed in your system:
- dotnet SDK 8.0 or higher
dotnet build
After cloning, restore local tools and enable the pre-commit hook:
dotnet tool restore
git config core.hooksPath hooksThis installs Fantomas and enables a pre-commit hook that checks F# formatting. If a commit is blocked, format the staged files with:
git diff --cached --name-only --diff-filter=ACM | grep '\.fs$' | xargs dotnet fantomasThe sample/ directory contains several example applications:
| Sample | Description |
|---|---|
Sample |
Basic Frank application |
Frank.OpenApi.Sample |
Product Catalog API demonstrating OpenAPI document generation |
Frank.Datastar.Basic |
Datastar integration with minimal HTML |
Frank.Datastar.Hox |
Datastar with Hox view engine |
Frank.Datastar.Oxpecker |
Datastar with Oxpecker.ViewEngine |
Frank.Falco |
Frank with Falco.Markup |
Frank.Giraffe |
Frank with Giraffe.ViewEngine |
Frank.Oxpecker |
Frank with Oxpecker.ViewEngine |
Frank.LinkedData.Sample |
Linked Data content negotiation with semantic RDF responses |
Frank.TicTacToe.Sample |
Stateful resource with affordance middleware, guards, and Datastar SSE |
- Design Documents — Design philosophy, vision, and architecture documents
- Frank.Statecharts Guide — Core concepts, hierarchical statechart support, guards, and test coverage overview
- Semantic Resources Vision — Agent-legible applications and the self-describing app architecture
- Spec Pipeline — Bidirectional design spec pipeline (WSD, SCXML, ALPS)
- How is this different from Webmachine or Freya? — Detailed comparison of Frank.Statecharts with Webmachine and Freya's approach to HTTP resource state machines