Why the name "Lazy Circus"? Managing multiple side effects in Haskell (databases, external APIs, logging, async execution) can often feel like a chaotic juggling actβa veritable "circus" of monad transformers and complex type constraints.
The typical workflow of an API service is straightforward:
- Accept a request
- Execute business logic
- Return a response
Libraries like servant handle steps 1 and 3 perfectly. However, the core value of your application undeniably lives in step 2. A robust business logic layer needs to handle multiple concerns: state computations, database transactions, 3rd-party API integrations, structured logging, and graceful error recovery.
Balancing these concerns often leads to messy codebases. As developers, we actually want:
- A simple mental model to reason about the program easily.
- Safe, error-resistant abstractions where making the right choice is easier than making the wrong one.
- Painless testability to verify complex logic without spinning up real infrastructure.
- Solid performance to minimize infrastructure costs.
Lazy Circus was built to enforce exactly these guarantees:
- Domain-Driven Effect Isolation: Effects are grouped into standalone DSLs (Domain Languages). When you write database logic, you don't have to worry about accidentally triggering an HTTP request.
- Regulated Interactions: The interaction between different effect languages is strictly regulated. For instance, the type system prevents you from making a long-running external API call in the middle of a database transaction.
- Out-of-the-box Testability: Everything is easily testable using mock interpreters (
Performers). You can run your production business scenarios using a test environment, inspect the exact sequence of triggered effects, and assert behavior without real side-effects. - Proven Performance: Built for real-world workloads. The application this library was originally developed for (a Telegram bot with heavy DB and API manipulations) comfortably handles ~4500 RPS in a Docker container (1 CPU, 512MB RAM) on a standard 2021 laptop.
Historical note: Lazy Circus was created in early 2025. It was originally developed as the foundation for a real-world production application and later open-sourced as a standalone library. Its design is heavily inspired by Alexander Granin's book Functional Design and Architecture.
Lazy Circus ships with a rich set of built-in capabilities ready for production use:
- Orchestration Framework: A robust control layer (
ScenarioProgram) for executing sub-scenarios, spawning asynchronous background tasks (drained by a configurable pool of async workers,runAsyncWorkerPool), managing environment state, handling errors gracefully, and calling registered services. You can orchestrate the built-in effects or plug in your own. - Database Effect DSL (via
beam): A comprehensive database language supporting both typed CRUD operations and raw queries. Advanced features like separate Read-Only connections, Row-Level Security (RLS), transaction management, and pessimistic row locking (findLocked/findAllLockedwithSELECT ... FOR UPDATEsemantics) are supported out-of-the-box. - Telegram Bot Integration: A ready-to-use effect for sending messages, documents, and reactions. It natively supports running multiple bots from a single application seamlessly.
- Mail Integration: A robust email effect for composing and sending transactional emails.
- AI Provider Integration (DeepSeek): AI effect DSL tailored for interacting with LLM providers like DeepSeek. It features out-of-the-box support for strict structured responses, a request-level
AIParamsoverlay β a right-biasedMonoidassembled fromwith*smart constructors (withModel,withTemperature,withMaxCompletionTokens, β¦) viarequestParams/agentParamsβ for OpenAI sampling parameters, an XML-like prompt templating language (POML) which significantly improves prompt adherence, amakePomlTemplateHaskell macro that compiles.pomlfiles into typed Haskell (Input -> [POML]) at compile time (with automatic recompilation on file edit), a<let src="..."/>declaration that inlines an external file (e.g. a JSON response-format schema) verbatim into a prompt at compile time, a pureparsePomlTextparser for runtime/test use, a multi-turnConversationhandle that threads context across calls, and an agent loop (solveWithAgent) that runs a ReAct cycle with tool use against registered services. - Production & Test Interpreters: Two distinct performers (
DefaultPerformerandTestInterpreter). The test runtime keeps the same shared-runner architecture as production, but swaps capabilities at the edges: DB can stay real, while Telegram, mail, AI, logging, and async work are captured through mocks in the capability layer. - Structured Async Logging: High-performance, asynchronous structured logging available universally across all effect types. It supports nested log contexts (e.g., automatically attaching a
user_idortrace_idto all subsequent nested calls). - Service Call Infrastructure: A transparent mechanism for running parallel typed workers with a standardized request/response interface. When the application needs concurrent background workers that process requests one at a time, register them through
LazyCircus.App.Serviceand call from scenarios withcallServiceβ no coupling to concrete implementations. ThemakeServiceLibTemplate Haskell macro generates the service library data type, config, dispatch instances, builder function, and optional tool-call plumbing for AI integration from a list of request/response/tool-spec triples. - AI Coding Agent Skill: A pre-configured custom AI skill instruction set located in
docs/skills/lazy-circus/to teach GitHub Copilot (or other AI assistants) the architectural patterns and conventions of Lazy Circus.
Lazy Circus strongly relies on domain-driven structures (Church-encoded free monads) that generic AI models might struggle to write perfectly on the first try. To solve this, we ship a custom Skill specifically designed for automated coding agents. With this skill applied, AI models at the level of GML-4.7 can successfully and accurately write business logic and tests using the framework.
When installed, the skill provides your AI assistant (e.g., GitHub Copilot) with deep context about:
- How to write and use effects via
ScriptandScenarioProgram - Appropriate usage of DB, Telegram, and Logging DSLs
- How to write tests with
TestInterpreterand assert side-effects - Haskell formatting and documentation rules used in the project
To install the Lazy Circus skill for your AI workspace:
# Install into ~/.opencode/skills/lazy-circus (default)
./install_skill.sh
# Or install into a custom parent skills directory
./install_skill.sh path/to/skillsThe script mirrors docs/skills/lazy-circus into the destination, removing stale files. To install manually instead, copy the docs/skills/lazy-circus folder into your project's AI skills directory (for example, .claude/skills/lazy-circus or a custom AI prompts folder).
Since lazy-circus is not yet available on Hackage, you need to install it directly from GitHub.
Using Cabal (cabal.project & package.yaml):
First, add the repository to your cabal.project file:
source-repository-package
type: git
location: https://github.com/BenefitWizard/lazy-circus.git
tag: <commit-hash>Then, add it to your dependencies in package.yaml (or build-depends directly in .cabal):
dependencies:
- lazy-circusUsing Stack (stack.yaml):
Add the library to your extra-deps with the repository and commit:
extra-deps:
- github: "BenefitWizard/lazy-circus"
commit: "<commit-hash>"- Overview
- 1. Effect Languages (Scene DSL)
- 2. Script β Effect Coproduct
- 3. ScenarioProgram β Orchestration Layer
- 4. Performer β Interpreters
- 5. DefaultPerformer β Production Runner
- 6. DB: Adding a New Table
- 7. Testing
- 8. Adding a New Effect
- 9. Key Patterns
- 10. Included Examples
- 11. Build and Tests
Lazy Circus is an effect framework for Haskell built on Church-encoded free monads (Control.Monad.Free.Church.F). Business logic is written using declarative Effect DSLs, completely decoupled from specific implementations (IO, mocks, etc.).
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ScenarioProgram s sl a β Orchestration Layer
β (logging, errors, time, async, context, services) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Script a β Coproduct (sum of effects)
β TelegramScript | MailScript | AIScript | DBScript | HTTPScript β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Scene-DSL (DBLangF, TelegramScriptF, ...) β Effect Functors (GADT)
β + built-in LogLangF for logging β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Performer (typeclass-interpreters) β Execution Layer
β runDB, runTelegram, runAI, runMail, runHTTP β
β + DefaultPerformer for production β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Each effect is described as a GADT functor. Church-encoding (F) transforms it into a monadic program.
| Language | Program Type | Module |
|---|---|---|
| Database | DBScript db a |
LazyCircus.Scene.DB |
| Telegram | TelegramScript a |
LazyCircus.Scene.Telegram |
| AI | AIScript a |
LazyCircus.Scene.AI |
MailScript a |
LazyCircus.Scene.Mail |
|
| HTTP | HTTPScript a |
LazyCircus.Scene.HTTP |
| Logging | built into all languages | LazyCircus.Scene.Log |
import LazyCircus.Scene.DB
-- Create a record
createAct :: CircusActT Maybe -> DBScript SimpleDb (Maybe CircusAct)
createAct act = create act
-- Find by ID
findAct :: Int32 -> DBScript SimpleDb (Maybe CircusAct)
findAct actId = find (CircusActId actId)
-- Update
updateDesc :: Text -> Int32 -> DBScript SimpleDb [CircusAct]
updateDesc newDesc actId = update patch (CircusActId actId)
where patch = CircusAct Nothing Nothing Nothing (Just newDesc) Nothing
-- Delete
deleteAct :: Int32 -> DBScript SimpleDb ()
deleteAct actId = delete (CircusActId actId)
-- Transaction
transactionalAct :: DBScript SimpleDb ()
transactionalAct = withTransaction $ do
mAct <- createAct myAct
_ <- forM mAct $ \act -> updateDesc "New desc" (circusActId act)
pure ()import LazyCircus.Scene.Telegram
greetUser :: SendMessageRequest -> TelegramScript (Response Message)
greetUser request = do
response <- sendMessage request
slogInfo "Message sent"
pure responseimport LazyCircus.Scene.Mail
notifyUser :: Address -> Text -> Text -> MailScript ()
notifyUser to subject body = do
mail <- makeMail to subject body
sendMail mailimport LazyCircus.Scene.AI
getAnswer :: AIRequest MyResponse -> AIScript (Maybe MyResponse)
getAnswer request = ask requestimport LazyCircus.Scene.HTTP
fetchData :: ClientM MyData -> HTTPScript (Either ClientError MyData)
fetchData request = runClient requestAll Scene languages have built-in logging via HasLogLang:
slogInfo "Informational message" -- level: Info
slogWarn "Warning message" -- level: Warn
slogError "Error message" -- level: Error
slogSensitive "Debug-only message" -- level: Debug (secret)
-- Logging context (key-value, nested)
swithLogCtx [("user_id", "42")] $ do
slogInfo "This message includes user_id in context"Script is a GADT that wraps any sub-language into a single type. Using smart constructors:
import LazyCircus (tgScript, mailScript, aiScript, httpScript, dbScript)
-- Wrap sub-languages
tgScript "my-bot-name" myTelegramScript :: Script b
mailScript myMailScript :: Script b
aiScript myAIScript :: Script b
httpScript myBaseUrl myHTTPScript :: Script b
dbScript myDb ReadWrite myDbScript :: Script baiScript wraps with an empty tool-description list. For tool-aware AI scripts, use AIScriptDef directly or the TH-generated aiScriptWithAll / aiScriptWith smart constructors (see Section 8).
For DB scripts, the dbScript smart constructor is the idiomatic wrapper; the underlying DBScriptDef constructor is also available via Script(..):
dbScript myDb ReadWrite myDbScript :: Script b
-- equivalent to: DBScriptDef myDb ReadWrite myDbScriptScenarioProgram s sl a is the main type for writing business scenarios. It adds:
evalScriptβ execute a nestedScriptthrow/runSafelyβ error handlinggetDateTimeβ get current timelog/logInfo,logWarn,logError,logSensitiveβ scenario-level loggingwithLogContext,withLogEntry,with2LogEntriesβ logging contextgetExtraContext,readFromExtraContext,getFeatureFlagβ configurationrunAsyncβ asynchronous executionrunArbitraryIOβ last-resort escape hatch that runs an arbitraryIO; runs for real in both production and tests (cannot be mocked), so prefer a scene language or servicecallServiceβ call a registered service via the service library
import LazyCircus.Scenario
import LazyCircus (tgScript, aiScript)
import LazyCircus.App.Service (NoServiceLib)
myScenario :: ScenarioProgram Script NoServiceLib ()
myScenario = do
logInfo "Starting scenario"
result <- runSafely $ do
-- Call AI
answer <- evalScript $ aiScript $ ask myRequest
-- Call Telegram
case answer of
Just response -> do
evalScript $ tgScript "bot" $ sendMessage (buildRequest response)
pure ()
Nothing ->
throw $ userError "AI returned nothing"
case result of
Left (e :: SomeException) ->
logError $ "Scenario failed: " <> tshow e
Right _ ->
logInfo "Scenario completed"Each sub-language has its own typeclass interpreter:
| Typeclass | Runner Method |
|---|---|
DBScriptPerformer m |
runDB :: PgDB db -> DbMode -> DBScript db a -> m a |
TelegramScriptPerformer m |
runTelegram :: TelegramScript a -> m a |
AILangPerformer m |
runAI :: AIScript a -> m a |
MailScriptPerformer m |
runMail :: MailScript a -> m a |
HTTPPerformer m |
runHTTP :: HTTPScript a -> m a |
ScenarioPerformer script serviceLib m |
run :: ScenarioProgram script serviceLib a -> m a |
Interpretation occurs via iterM β folding the Church-encoded free monad into the target monadic context.
All sub-languages use a common handleLogLang handler, which:
- Extracts the current
LoggingContextfrom the reader environment - Adds a language tag (
"DB","Telegram","AI","Mail","HTTP") - Extracts the call site
- Writes to a shared
TQueue
The LazyCircus.Performer.Default module provides a ready-to-use production interpreter:
import LazyCircus.Performer.Default
-- Run scenario in production environment via the generic 'run' function
runRIO app $ runDefaultPerformer $ run @Script @serviceLib myScenario
-- Start the background workers that drain the async task queue
-- (runAsyncWorkerPool n runs n competing workers over the shared queue;
-- n = 0 is clamped to 1 and n > 1024 to 1024, each with a warning)
asyncThread <- async $ runRIO app $
runAsyncWorkerPool 4 (runDefaultPerformer . run @Script @serviceLib)DefaultApp serviceLib contains:
Pool Connectionβ main PostgreSQL connection pool (pgDbPool; total connection cap configured viacfgPgPoolMaxResourcesinDefaultAppConfig). Every DB script checks out one connection from the pool for its entire duration, so concurrent scripts (including async workers) never share a PostgreSQL session.Maybe (Pool Connection)β optional read-only PostgreSQL pool (pgDbPoolReadOnly;ReadOnlyscripts fall back to the main pool when it is unset)BotEnvsβMap Text BotEnvfor Telegram botsMethodsβ OpenAI clientMailCredsβ SMTP credentialsLogQueue/LoggingContextβ loggingJWTSettingsβ authorizationExtraContextβ arbitrary configuration (HashMap Text Text)ScheduledActionsβ queue of async tasks, drained byrunAsyncWorker/runAsyncWorkerPoolserviceLibβ in-process service handlers (orNoServiceLib)appToolDescriptionsβ tool descriptions available to AI interpreterstoolCallExecβ closure that dispatches named tool calls with JSON argumentshttpManagerβ shared TLS connection manager for HTTP client requests
newDefaultApp probes each configured pool at construction, so an unreachable database
fails startup. The caller is responsible for releasing the pools via destroyAllResources
after cancelling worker threads.
To make a new Beam table work with DBScript, you need to implement service instances:
import LazyCircus.DB.Service
-- 1. Table must be in the DB schema
instance IsInDb MyDb MyTableT where
getTargetTable = _myTableEntity
-- 2. For INSERT
instance HasCreateService MyDb MyTableT where
generateInsert db rows = insert (_myTableEntity db) $
insertExpressions $ map (\r -> ...) rows
-- 3. For SELECT (defines lookup key type)
instance HasReadService MyDb MyTableT where
type LId MyTableT = MyTableId -- injective type family
generateSelect db lid = lookup_ (_myTableEntity db) lid
generateFiltration _db lid t = myTableId t ==. val_ (unMyTableId lid)
-- 4. For UPDATE
instance HasUpdateService MyDb MyTableT where
generateAssigment _db partial t = ...
-- 5. For DELETE (often default implementation is enough)
instance HasDeleteService MyDb MyTableTReadWriteβ all operations allowedReadOnlyβ read-only; write operations throwDbReadOnlyViolation
import LazyCircus.Scene.DB
-- Transaction with RLS context
withTransactionRLS (RLSContext [("circus_id", "42")]) $ do
findAll someLookup -- only sees rows where circus_id = 42The LazyCircus.Testing.Performer module provides a mock interpreter for tests:
Tests reuse the same high-level runner structure as production:
ScenarioProgramis interpreted through the normal scenario machineryScriptdispatch still routes into DB / Telegram / Mail / AI / HTTP branches- environment projection still happens through wrapper envs such as
AppWithConnectionandAppWithBotEnv
What changes is the capability layer underneath that runner:
- DB typically uses a real PostgreSQL connection checked out from the app's pool (one connection per DB script)
- Telegram uses capture mocks for immediate and scheduled sends
- Mail uses real mail building but mocked send capture
- AI returns mock answers (
Nothingby default) - HTTP executes real servant-client requests via the configured manager and base URL
runAsyncrecords deferred scenarios instead of executing them (default; withtcAsync = Realthe worker is spawned through the same test interpreter)- logging is captured as structured messages with context and call-site metadata
This shared-runner design lets tests validate orchestration behavior very close to production while still giving precise observability over side effects.
import LazyCircus.Testing.Performer
import LazyCircus.App.Service (NoServiceLib)
myTest :: DefaultApp NoServiceLib -> IO ()
myTest app = do
(mocks, result) <- runWithDefaultMocks app $ do
runScenarioProgram myScenario
-- Check captured Telegram requests
tgReqs <- readTgRequests mocks
tgReqs `shouldSatisfy` (not . null)
-- Check logs
logs <- readLog mocks
logs `shouldSatisfy` elem (AppLogMsg "Scenario completed")
-- Check sent emails
mails <- readSentMails mocks
mails `shouldSatisfy` (not . null)
-- Check async tasks
asyncs <- readScheduledScenarios mocks
length asyncs `shouldBe` 1-- Simple mock (default response for all sendMessage)
mocks <- makeMocks
-- Custom mock with response queue
tgMock <- createTgMock defaultResponse $ Just [myCustomResponse]| Effect | Behavior in Tests |
|---|---|
Telegram sendMessage |
Captures requests, returns canned responses, and publishes an OutgoingMessage (with a fresh incremental MessageId) to the STM outgoingMailbox |
Telegram sendDocument |
Publishes an OutSendDocument to the outgoingMailbox (incremental MessageId); not added to readTgRequests |
Telegram setMessageReaction |
Publishes an OutSetReaction to the outgoingMailbox |
Telegram editMessageText |
Publishes an OutEditMessage; still returns Nothing |
Telegram scheduleMessages |
Captured in a separate list |
| Missing Telegram bot | Throws NoBotConfigured during script dispatch |
| Telegram others | no-op / default |
Mail sendMail |
Capture Mail values |
Mail makeMail |
Actual creation via SMTP credentials from env |
AI ask |
Always returns Nothing |
HTTP runClient |
Real execution via servant-client against target base URL |
| DB | Real execution against DB (requires test database) |
| Logging | Capture in ref-lists (no production queue writes) |
runAsync |
Capture ScenarioProgram without execution (default tcAsync = Mocked; tcAsync = Real spawns the worker so its side effects land in the capture buffers) |
- use
readTgRequeststo inspect immediate Telegram sends - use
readScheduledTgRequeststo inspect deferred Telegram sends - use
readOutgoingMailbox+OutgoingKind(OutSendMessage/OutSendDocument/OutSetReaction/OutEditMessage) to assert on side-effect kinds and ordering, including the incrementalMessageIdstamped on each send - use
readScheduledScenariosto inspectrunAsynccapture - use
readLogWithContextwhen you need to assertwithLogContext,lang, or call-site metadata - if needed, rerun a captured async scenario in the same test runtime to verify its downstream effects
LazyCircus.Testing.TgTest provides a higher-level runner for testing the bot's
own update handler end-to-end. It drives the same Update -> IO () seam
production uses (via runHeadlessBot), with Telegram/AI/mail mocked by the test
performer and replies landing in the shared STM outgoingMailbox. You write a
TelegramTestScript that sends fake user input and waits for the bot's replies:
import LazyCircus.Testing.TgTest
( TelegramTestScript, sendMessage, waitForReply, guardWith )
newactPilot :: TelegramTestScript ()
newactPilot = do
_ <- sendMessage "/newact"
r1 <- waitForReply
guardWith "expected the act-name prompt" (r1 == "π Enter act name:")
_ <- sendMessage "Fire Juggling"
-- ...continue the dialogwaitFor* operations block deterministically via STM (no polling) and fail with
TgTestTimeout on timeout. For building fake Updates in synchronous handler
tests, use LazyCircus.Testing.Updates (mkTextUpdate, mkCallbackQueryUpdate,
the stateful UpdateFactory, etc.). See test/TgTestSpec.hs,
test/TgMockMailboxSpec.hs, and test/BotHandlerSpec.hs for worked examples,
and the skill reference (runtime-testing.md)
for the full DSL.
To add a new sub-language:
-- src/LazyCircus/Scene/MyEffect/Lang.hs
module LazyCircus.Scene.MyEffect.Lang where
import Control.Monad.Free.Church (F)
import Control.Monad.Free.Church qualified as CF
import LazyCircus.Scene.Log (HasLogLang (..), LogLangF)
data MyEffectF a where
DoSomething :: Text -> (Result -> a) -> MyEffectF a
MyEffectLog :: LogLangF MyEffect b -> (b -> a) -> MyEffectF a
instance Functor MyEffectF where
fmap f (DoSomething txt next) = DoSomething txt (f . next)
fmap f (MyEffectLog logOp next) = MyEffectLog logOp (f . next)
instance HasLogLang MyEffectF MyEffect where
embedLog logOp = MyEffectLog logOp id
doSomething :: Text -> MyEffect Result
doSomething txt = CF.liftF $ DoSomething txt id
type MyEffect = F MyEffectF-- src/LazyCircus/Scene/MyEffect/Class.hs
module LazyCircus.Scene.MyEffect.Class where
import Control.Monad.Free.Church (iterM)
import LazyCircus.Scene.MyEffect.Lang
import LazyCircus.Scene.Log (handleLogLang)
class (Monad m) => MyEffectPerformer m where
doSomething' :: Text -> m Result
runMyEffect :: (MyEffectPerformer m, HasLogQueue env, HasLoggingContext env,
MonadReader env m, MonadIO m) => MyEffect a -> m a
runMyEffect = iterM go
where
go (DoSomething txt next) = do
result <- doSomething' txt
next result
go (MyEffectLog logOp next) = handleLogLang "MyEffect" runMyEffect (fmap next logOp)-- In Script.hs add constructor:
data Script b where
...
MyEffectDef :: MyEffect b -> Script b
-- In Performer.hs update onEvalScript:
onEvalScript (MyEffectDef scr) = runMyEffect scrAll resources are accessible via lens-based typeclasses:
class HasDbConnection env where
dbConnectionL :: Lens' env Connection
class HasLogQueue env where
logQueueL :: Lens' env (TQueue AppLogMsgWithContext)
class HasLoggingContext env where
logContextL :: Lens' env LoggingContextThis allows composing environments via wrapper types (AppWithConnection, AppWithBotEnv).
type Runner r m = forall a. r a -> m aA rank-2 alias for natural transformations from an effect language to an interpreting monad.
changeEnv :: (outer -> inner) -> DefaultPerformer inner a -> DefaultPerformer outer aProjects a performer onto an outer environment via a projection function.
The repository ships with two ready-to-run executables and a shared internal library that demonstrates real-world usage of every Lazy Circus sub-language.
Entry point: app/example/Example.hs
Run: ./run-example.sh or stack run example
The demo first runs a standalone POML TemplateHaskell preamble (Example.PomlDemo.runPomlDemo) that exercises the makePoml macro against the templates in app/example/prompts/ (hello.poml, greeting.poml, contact.poml) and prints the rendered prompt text. It needs no external services.
It then connects to PostgreSQL, runs eight scenarios sequentially, and prints the result of each:
| # | Scenario | What it demonstrates |
|---|---|---|
| 1 | dbCrudScenario |
Basic CRUD: create β find β update β findAll β delete |
| 2 | dbAdvancedScenario |
createMany, withTransaction, rawQuery, withTransactionRLS |
| 3 | telegramScenario |
getBotName, sendMessage, sendImportantMessage, scheduleMessage, setMessageReaction, editMessageText (skipped if TG_CHAT_ID not set) |
| 4 | mailScenario |
makeMail + sendMail |
| 5 | aiScenario |
Typed AIRequest with structured JSON decoding (skipped if AI_API_KEY not set) |
| 6 | loggingScenario |
All four log levels, withLogContext, withLogEntry, sub-language logging via swithLogCtx |
| 7 | orchestrationScenario |
getDateTime, getExtraContext, readFromExtraContext, getFeatureFlag, throw/runSafely, runAsync |
| 8 | fullCircusLifecycleScenario |
End-to-end: DB create in transaction β extra context β logging context β mail β async cleanup β runSafely |
Entry point: app/bot/BotMain.hs
Run: ./run-bot.sh or stack run bot
Requires: TG_TOKEN environment variable
A conversational Telegram bot that manages circus acts. Commands:
| Command | Behaviour |
|---|---|
/start |
Shows help message |
/newact |
Two-step dialog: name β description β creates act + AI reaction + notification email |
/list |
Lists all acts from DB |
/act <id> |
Shows act details |
/react <id> |
Regenerates AI audience reaction for an act |
/delete <id> |
Deletes an act |
| free text | Runs the AI agent (solveWithAgentContinuing) and replies |
The Telegram layer lives in BotHandler (handleScenario / updateAction /
runUpdate): it routes each Update, drives the dialog FSM, and delegates the
business logic to BotScenarios. Replies go through the TelegramScript DSL
(tgScript "demo-bot"), so they share the same routing, structured logging, and
automatic timing as every other Lazy Circus Telegram effect. Per-chat
serialization is provided by ChatStateStore.
Both examples need PostgreSQL running at 127.0.0.1:5432 (user postgres, password my_password). The demo automatically creates/drops the lazy_circus_test database. A docker-compose.yml is provided for convenience. Set ASYNC_WORKERS to configure how many background workers drain the demo's async task queue (default 1).
docker compose up -d # start PostgreSQL
cp .env.example .env # configure API keys and tokens# Update cabal file (required before building)
hpack
# Build project
stack build
# Run tests
stack testDB tests require a running PostgreSQL with user postgres (password my_password) on 127.0.0.1:5432. Tests create and drop the lazy_circus_test database automatically.