falaw
Agent-friendly Python facade over fal.ai for generating and managing AI media (images, video, audio).
from falaw import generate_image, list_models, journal
r = generate_image("a tiger eye, macro, 35mm", quality="fast")
r.first.download(to="./tiger.png")
[m.id for m in list_models(category="video")]
journal.note("schnell at quality='fast' defaults to 1024x1024")
Why
fal-client already gives you 100+ models behind a uniform call. What
agents (and humans) still struggle with is which model to use, what
parameters it takes, and what to do with the URL it returns. falaw
adds:
- Task-level verbs (
generate_image,text_to_speech, ...) with smart model selection by quality tier. - A queryable model registry --- no more grepping docs for IDs.
Result/Assetobjects that download, name, and organize outputs.- A journal so each session leaves notes for the next one.
- A Claude skill, plus stub bridges for MCP and HTTP services --- all derived from the same tool registry.
Install
pip install -e .
export FAL_KEY="your-fal-api-key"
Core surface
| Function | Purpose |
|---|---|
generate_image(prompt, *, quality, image_size, model_id, extra) |
Text-to-image, picks FLUX by quality tier. |
text_to_speech(text, *, quality, voice, model_id, extra) |
TTS, picks a voice model by tier. |
list_models(*, category, quality_tier) |
Browse the catalog. |
pick_model(*, category, quality_tier) |
Pick a sensible default. |
call_fal(application, arguments, *, on_event) |
Escape hatch to any fal model. Emits ProgressEvents + auto-journals on error. |
cached_call_fal(...) |
Same, plus content-addressed cache; emits cache_hit events on reuse. |
execute_plan(plan, *, concurrency=N) |
Run a Plan → list[Artifact]. Raises on the first failure, unwrapped. |
execute_plan_isolated(plan, *, concurrency=N) |
Run a Plan → ExecutionReport: one outcome per call, so one bad call does not discard the rest. |
plan_dependencies(plan) |
The Plan's "<from N>" dependency DAG, and its structural validator. |
render_scene(scene, *, concurrency=N) / iter_render_scene(...) |
Render every shot+beat; thread-pooled, with yield-as-done iterator. |
estimate_scene_cost(scene) |
Walk a Scene, return a CostRollup with per-line USD breakdown. |
subscribe(callback) |
Attach a global subscriber to the ProgressEvent bus. |
journal.note / issue / improvement(...) |
Leave a trace for future sessions. |
Session(output_dir=...) |
Optional stateful controller. |
Structured progress events
call_fal and cached_call_fal emit ProgressEvents at every
lifecycle transition (queued, progress, log, done, error,
cache_hit). Subscribe per-call (on_event=) or globally
(falaw.subscribe(...)); the legacy on_log=print is still honored
for backward compatibility.
from falaw import subscribe, generate_image
subscribe(lambda ev: print(f"[{ev.kind}] {ev.application} {ev.elapsed_s:.2f}s"))
generate_image("a tiger eye", quality="fast")
Cost estimation
ModelRecord.cost_estimate: CostEstimate | None carries a structured
{kind, amount, currency} price (kinds: per_call | per_image | per_second | per_token | per_megapixel). estimate_scene_cost(scene)
sums per-call costs and returns a CostRollup with per-line
breakdown. Models without a populated cost_estimate appear in the
rollup's skipped list so audits surface drift.
Fan-out: partial results, bounded concurrency, per-call isolation
A Plan is a fan-out — 200 panels is 200 CallPlans in one Plan — so
execute_plan's list[Artifact] has no room to say "call 7 failed, here are
the other 199". execute_plan_isolated does:
from falaw import execute_plan_isolated
report = execute_plan_isolated(plan, concurrency=8)
for outcome in report.outcomes: # always one per call, in plan order
if outcome.ok:
save(outcome.artifact)
elif outcome.status == "failed":
retry_later(outcome.call, outcome.error) # retry this call verbatim
else:
replan(outcome.call, outcome.reason) # blocked: its input does not exist
report.estimated_spend_usd, report.has_unknown_costs, report.cache_hit_savings_usd
Three states, not two. Failed means the call raised and can be retried as
it is. Blocked means it never ran — a "<from N>" placeholder whose
producer did not succeed — so it has to be re-planned, not retried. A caller
that cannot tell them apart retries a call whose input does not exist.
concurrency bounds how many calls are in flight; independent calls run in
parallel, and a call is never started beside the producer it consumes. It
defaults to 1 (sequential) everywhere, here and in render_scene, because
every call is a paid request: parallelism is opt-in. Weigh the vendor's rate
limit and memory — materializing a media result peaks at roughly twice the
asset's size, and concurrency multiplies that.
execute_plan keeps the plain list[Artifact] contract and re-raises the
first failure unwrapped, so falaw's typed error hierarchy still reaches the
caller. It is execute_plan_isolated(...).artifacts_or_raise() — one engine,
two policies.
render_scene(..., concurrency=4) runs shots and beats in parallel through the
same kind of thread pool (fal calls are HTTP-bound). Use
iter_render_scene(...) to yield (kind, result) pairs as each unit completes
— handy for live UI updates.
Testing code that uses falaw
falaw content-addresses every media result, so execute_plan reads the
bytes behind each result URL. A suite that stubs the fal response but not
the asset transport is resolving its made-up URLs for real — and it passes
while doing so, because a failed fetch degrades to a URL-only artifact with a
UserWarning rather than raising.
falaw ships the fake, so nobody has to write one. Three autouse fixtures, each
re-exportable into a conftest.py in one line:
# conftest.py
from falaw.testing import ( # noqa: F401
fake_assets, # url -> bytes, served from memory
isolated_falaw_cache, # cache / content store / url-index under tmp_path
no_outbound_network, # refuses AND records any non-loopback connection
)
def test_two_urls_one_content_address(fake_assets):
shared = fake_assets.serve("http://cdn/a.png", b"identical bytes")
fake_assets.serve("http://cdn/b.png", shared) # pin explicit bytes
fake_assets.fail("http://cdn/gone.png") # 404, as an expired asset does
...
assert fake_assets.fetched == [...] # assert on the record
fake_assets installs itself through falaw.content.using_url_fetcher, the
public transport seam, so it covers execute_plan, execute_plan_isolated,
materialize_asset and content_ref_for_url at once — including calls your
own public API makes, which have no asset_fetcher= argument to pass. An
explicitly passed fetcher= still wins. file:// URLs are not faked (they
are not the network); pin one explicitly if you want it served from memory.
using_url_fetcher is not test-only: it is also how you give falaw an
authenticated, retrying, or mirrored transport in production, in one place
instead of at every call site.
Two rules that are easy to get wrong:
- Don't reach for
FALAW_FETCH_ARTIFACT_BYTES=0to make a suite offline. It works by turning content addressing off — the suite goes hermetic and stops testing the feature. - Assert on the record, not on an exception. falaw absorbs a failed fetch
by design, so a guard that only raises is invisible. That is why both the
fake and the network guard record every attempt, and why
no_outbound_networkfails the test at teardown.
Marker live_api opts a test back into the real transport and no network
guard. A suite with different marker names builds its own fixtures from
make_fake_assets_fixture / make_no_outbound_network_fixture.
Architecture
Single source of truth: a ToolSpec dataclass per tool. From it we derive every external surface:
falaw.registry ──► bridges/skill.py ──► .claude/skills/falaw/SKILL.md
──► bridges/mcp.py ──► MCP server (planned)
──► bridges/service.py ──► qh HTTP service (planned)
──► (UI) (planned)
Adding a new surface is a new bridge module, never a re-implementation of the operations.
Self-improvement loop
Every session can read and write the agent journal at ~/.config/falaw/journal/. The Claude skill instructs Claude to:
- Read recent entries before novel work.
- Write a note / issue / improvement when something surprises it.
call_fal auto-journals failures with the application id and arguments, so the next session recognizes the trap.
Layout
falaw/
base.py ToolSpec, ModelRecord
core.py call_fal: subscribe + auto-journal
registry.py register_tool, list/get/pick model
results.py Asset, Result, parse_response
session.py Session
journal.py file-backed journal
operations/
images.py generate_image
audio.py text_to_speech
bridges/
skill.py render Claude SKILL.md from registry
mcp.py (stub)
service.py (stub)
data/
models.json seed catalog
skills/falaw/ generated skill files (shipped with package)
misc/
docs/ aggregated fal.ai docs (3MB md, llms.txt, llms-full.txt)
regenerate_skill.py
tests/
Regenerate the skill after adding a tool
python misc/regenerate_skill.py
Writes falaw/data/skills/falaw/SKILL.md and .claude/skills/falaw/SKILL.md.
Status
v0 --- functional core, real Claude skill, stubs for MCP and HTTP service. The bridges share the same registry, so filling in the stubs is additive.
Roadmap
See misc/docs/roadmap.md for the ordered work and the standing
constraints. In short, four tracks:
- Content addressing --- landed (falaw#14).
Artifact.asset_idis the SHA-256 of the artifact's bytes and the per-call cache key holds upstream content hashes, not fal CDN URLs. fal states that every upload gets a unique URL and that expired files are permanently deleted, so URL-keying meant a byte-identical regeneration missed the cache and a stored response decayed into a dead link. Bytes now route throughlacing.ArtifactStore.put_blob_stream. Still open: the plan-time cache peek keys on unresolved arguments (D2), which lands with track 2. - Backend-parametric
Plan---CallPlan.backend+ registry dispatch inexecute_plan, so a second execution backend is still priced, cached and dry-runnable. - Licence-and-terms ledger --- per
(model, backend), queried at plan time,unknownmeans refuse. - Cost data from the vendor --- read fal's pricing API instead of hand-maintaining
data/models.json(19 of 40 records currently carry no structured price at all).
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file falaw-0.0.33.tar.gz.
File metadata
- Download URL: falaw-0.0.33.tar.gz
- Upload date:
- Size: 1.8 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
11636fa6d98098c10bd7f6ff306cf79bfe3509977183d261a63fa4dd1b2b1e51
|
|
| MD5 |
1858adcecfe35291eaf834f8439fab91
|
|
| BLAKE2b-256 |
9ba85a3bbfef7cfcc6219b4f9c15469f9cd9769e99a28129638187c29787a41e
|
File details
Details for the file falaw-0.0.33-py3-none-any.whl.
File metadata
- Download URL: falaw-0.0.33-py3-none-any.whl
- Upload date:
- Size: 162.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
537b0445aaf23ec49a66ba825cb9a5cdf49a8ed8312fab3125080ca986bc6090
|
|
| MD5 |
319f308abd7319ca0563108f867a56bd
|
|
| BLAKE2b-256 |
3743649eb718b42df5b65bf7a83aca8507c9cda4c687af75c847480613dd28ab
|