Resource-constrained scheduling for Tempo. Tempo answers when is this free? Agenda answers what should I book, and where? It adds the named resources, the attributes describing them, the places containing them, and the requirements a session places on them — so that a scheduling question can be asked the way a person would ask it.

Features

  • Resources, attributes and places — named resources with attributes, in a tree of places from which the travel time between them is derived.

  • Requirements that explain themselves — what a session needs, as predicates, with a sentence for every failed match, and a person's needs folded into the room they are booked in.

  • Planning and arranging — one session ranked against a pool, or a whole programme of tracks laid out: partially where not everything fits, around pinned placements, and with a minimal conflict set when it cannot be.

  • Order, contracts and wishes — precedence between sessions, load limits in claims or in time with floors and ceilings, and soft preferences, including each resource's own.

  • One ledger of claims — allocations, holds, and work and leave told apart by a tag, in one immutable value from which availability is derived and against which a period is reconciled.

  • Open hours from a calendar — RFC 7953 VAVAILABILITY read with the optional ical dependency.

  • iCalendar's vocabulary — busy time as BUSY or BUSY-TENTATIVE, hold, confirm and cancel as STATUS, calendar user and location types, and recurrence identity, each mapping recorded with what an export loses.

Meeting and conference scheduling is the focus. Task and shift scheduling are expressible — precedence makes a task graph, limits make a contract, wishes make a roster people will accept — and exact at small scale, or handed to a solver at large.

Supported Elixir and OTP versions

Agenda supports Elixir 1.17 and later on Erlang/OTP 27 and later, the floor it takes from Tempo.

Installation

Add agenda to the dependencies in mix.exs:

def deps do
  [
    {:agenda, "~> 0.3"}
  ]
end

Add ical ~> 3.2 as well to read RFC 7953 VAVAILABILITY; 3.2 is the first release carrying it.

Configure a time zone database

Elixir ships a UTC-only time zone database, and with it every zone resolves to UTC and daylight-saving arithmetic is silently wrong rather than failing — a London window across the spring-forward boundary measures an hour longer than it is. Any application doing zoned scheduling configures a real IANA database; Tz comes with Tempo:

config :elixir, :time_zone_database, Tz.TimeZoneDatabase

Agenda does not choose one for you.

Quick start

import Agenda.Predicate
import Tempo.Sigils

level_2 = Agenda.place("Level 2", within: Agenda.place("Convention Centre"))

boardroom      = Agenda.resource("Boardroom", within: level_2, seats: 8, video_conferencing: true)
meeting_room_2 = Agenda.resource("Meeting room 2", within: level_2, seats: 4, video_conferencing: true)
attic          = Agenda.resource("Attic", within: level_2, seats: 12, video_conferencing: true)
alice          = Agenda.resource("Alice", requires: [step_free_access: true])

rooms = [boardroom, meeting_room_2, attic]

needs_a_room = Agenda.needs(:room, seats: at_least(8), video_conferencing: true)

Agenda.eligible(needs_a_room, rooms)
|> Enum.map(& &1.name)
#=> ["Boardroom", "Attic"]

"Which rooms seat at least eight and have video conferencing?"

Why

Every scheduling system rebuilds the same three things above the calendar: a map of resources, hand-rolled filters over its keys, and a hard-coded travel matrix. None of it is what makes the product interesting, and getting it wrong produces meetings nobody can attend.

Two ideas do most of the work.

A failed match is a sentence, not a false. Eligibility and its explanation are computed together, so the reason a room does not qualify is always available:

Agenda.explain(needs_a_room, meeting_room_2)
#=> "Meeting room 2: seats is 4 — needs at least 8"

A person's needs bind the room. step_free_access: true on Alice is not a fact about her availability — it is a constraint she places on wherever she is booked. Folding her requirements into the room requirement means accessibility cannot be forgotten at the call site:

needs_a_room
|> Agenda.Requirement.induce([alice])
|> Agenda.explain(attic)
#=> "Attic: no step_free_access — needs true"

"The attic is out because Alice cannot get into it."

Places are a tree, so travel is derived

A place contains resources and other places, to whatever depth suits — campus, building, floor, wing. The library never interprets what a level means, only how the levels nest. Travel time then falls out of the structure rather than being configured:

level_3    = Agenda.place("Level 3", within: Agenda.place("Convention Centre"))
upstairs   = Agenda.resource("Upstairs Room", within: level_3, seats: 20)
main_stage = Agenda.resource("Main Stage", within: Agenda.place("Riverside Theatre"))

Agenda.travel_time(boardroom, upstairs)
#=> {:ok, ~o"PT5M"}

Agenda.travel_time(boardroom, main_stage)
#=> {:error, :unknown}

"Getting upstairs takes five minutes; nothing can be said about getting to the other venue until someone measures it."

Unrelated places return {:error, :unknown} rather than a guess, and any specific pair can be overridden where the building disagrees with the geometry:

Agenda.travel_time(boardroom, main_stage, between: [{{"Boardroom", "Main Stage"}, ~o"PT25M"}])
#=> {:ok, ~o"PT25M"}

This is what a flat location: :sydney attribute cannot do. It can answer "is this room in Sydney?", but not "can a delegate get from here to there in the ten-minute break?" — and only the second question decides whether a programme is workable.

When not everything fits

Three things separate a scheduler you can ship from one that only works on the happy path, and all three are about failure.

Place what you can. By default an unplaceable session fails the whole programme, which is right when the programme is a unit and wrong when it is a wish list. unplaced: :allow leaves out as few sessions as the search can manage — and says so, under a tag that cannot be mistaken for success:

case Agenda.arrange(programme, rooms, unplaced: :allow) do
  {:ok, arrangements} -> publish(arrangements)
  {:partial, layout}  -> review(layout.placed, layout.unplaced)
  {:error, reason}    -> abandon(reason)
end

Hold the announced sessions still. A published programme gets edited, and the keynote must not move. :pinned fixes chosen placements and searches around them, with every constraint still applying to the pins:

Agenda.arrange(programme, rooms, pinned: already_announced)

Say which sessions are actually in tension. "No arrangement found" is a dead end. conflict/3 returns a minimal set — remove any one member and the rest fit:

Agenda.conflict(programme, rooms)
#=> {:ok, ["Keynote", "Workshop", "Panel"]}

"Any two of these three fit. Choose which one moves."

It works on a single session too, naming the demands that are impossible together — including the ones a person induced rather than the session asking for them:

Agenda.conflict(session, rooms)
#=> {:ok, [needs: {:room, :video_conferencing}, requires: {"Alice", :step_free_access}]}

The ledger, and why nothing stores availability

A ledger is the record of what is allocated and to whom. It is the only place a booking lives, and everything else is computed from it rather than kept alongside it: free/2 is a resource's open hours minus what the ledger holds, worked out when asked. There is no availability field to update, so availability cannot drift out of step with the bookings it came from.

{:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement)

Agenda.free(boardroom, within: week, busy: Agenda.busy(ledger))

A ledger is an ordinary immutable value, not a running store — and that is deliberate rather than unfinished. It is what lets arrange/3 fork a ledger, try a layout on it, abandon it and try another, which a shared mutable one could not survive. Durable state, concurrent claims and a clock belong in whatever persists the answer; the ledger stays the thing they persist.

Order, contracts, and what people would rather

Three constraints that turn meeting scheduling into task and shift scheduling. Each answers a different question, and reaching for the wrong one is the commonest modelling mistake.

Order. An installation cannot precede its survey. That is not a clash and not a track:

{:ok, programme} = Agenda.precede(programme, "Survey", "Installation", gap: "PT1H")

:within caps the other end — gap: "PT15M", within: "PT2H" is what makes an interview loop a loop rather than two appointments a fortnight apart. Both are measured from the predecessor's end, so overrunning pushes the successor rather than eating its allowance. Because it relates exactly two sessions it is a pairwise constraint, so the solver bridge enforces it too.

Contracts. Eight open hours is not eight jobs:

Agenda.resource("Dana", limits: [day: 3, week: 12])
Agenda.resource("Dana", limits: [day: ~o"PT7H36M", week: [at_least: ~o"PT38H"]])

This is not concurrency. Concurrency is how many claims may overlap at one instant; a limit is how many fall inside a stretch of calendar, however far apart. And limits count what the ledger already holds — availability can be derived, but no availability calculation can express "at most twelve this week".

A limit measures either claims or time, and may set a floor as well as a ceiling. Only ceilings constrain the search, and the asymmetry is real rather than an omission: a ceiling prunes, because nothing placed later brings a total back down, while a partial layout is supposed to be under its floor. A floor is a completion condition, so it is reconcile/3 that checks it.

Wishes. What someone would rather, as against what is possible:

Agenda.resource("Priya", avoids: friday_afternoons)
{:ok, programme} = Agenda.prefer(programme, :resource_wishes, weight: 5)

avoids makes a placement worse; open/2 makes it impossible. Spelling a preference as unavailability takes the option away from everyone and fails the week rather than booking reluctantly when there is no alternative.

Does the week add up?

A schedule is the ledger read as intent; a timesheet is the same ledger read as record. reconcile/3 compares the two over a period:

{:ok, report} = Agenda.reconcile(ledger, dana, within: quarter, excluding: holidays)

Agenda.Reconciliation.explain(report)
#=> ["Dana: 5 hours unaccounted — 2026Y6M16DT12H0M0S/T17H0M0S"]

The answer is a set, not a total. Summing hours and comparing to a number passes on data that is wrong: a consultant who misses a Tuesday and works the following Saturday totals exactly the same as one who did neither. unaccounted and overclaimed are interval sets, so they say which time is missing — which is what a person can act on.

Work and leave are the same structure, separated by a tag on the claim:

{:ok, ledger} = Agenda.allocate(ledger, arrangement, tag: {:project, "ACME-2026-01"})
{:ok, ledger} = Agenda.allocate(ledger, arrangement, tag: {:leave, :annual})

One ledger means a day cannot be two things — billing a client while on leave is the overlap the ledger already refuses, not a rule somebody has to remember to write.

Holidays are not this library's business. They vary by jurisdiction down to the local government area — Queensland appoints show holidays per district — and accurate data for that is a maintenance problem with a far wider audience than scheduling. So they arrive as an ordinary interval set through :excluding, from a feed via from_ical/1 or from wherever you resolve them. The subtraction order does one useful thing for free: holidays leave the expectation before claims are compared against it, so a public holiday inside a period of leave cannot consume that leave.

Holding a room while someone finds their card

A booking page needs to take a room tentatively. A hold occupies the resource exactly as a booking does — which is why it lives in the ledger rather than in a persistence layer: availability is derived on every call, so a hold nobody can see is a hold nobody subtracts.

{:ok, ledger} = Agenda.hold(ledger, arrangement, until: "2026-06-15T10:15:00")
{:ok, ledger} = Agenda.confirm(ledger, "Review")

Nothing expires on its own. expire/2 takes the moment as an argument rather than reading a clock:

{:ok, ledger} = Agenda.expire(ledger, now)

That is deliberate. busy/2 is called inside plan/3 and arrange/3; if it consulted the system clock, arranging the same programme twice would give different answers as holds lapsed underneath it. Refusing to guess the time is the same discipline as refusing to guess an unmeasured journey.

A session is held, confirmed or cancelled: cancel/2 frees what it held, because that changes what is available. The rest of the claim lifecycle — completed, no-show — is deliberately not here: a no-show changes nothing that is available. That line is what separates the engine's business from the adapter's.

Preferring one workable layout over another

Every constraint above is hard. A preference is soft: it never makes a layout invalid, only worse.

{:ok, programme} = Agenda.prefer(programme, :room_changes, weight: 10)
{:ok, programme} = Agenda.prefer(programme, :room_spread, weight: 3)

Agenda.explain_score(arrangements, programme)
#=> ["room_changes: 0 × 10 = 0", "room_spread: 0 × 3 = 0"]

Preferences count violations, so zero is ideal — a number that means something on its own, where a reward total only means something next to another reward total.

Optimisation is lexicographic and two-pass, and that is the whole design. The first pass ignores preferences and proves how many sessions can be placed. The second takes that number as a hard ceiling and looks only for a better-scoring layout placing exactly as many. So a preference can never cost you a session, minimal? still means proven, and the new score_proven? says whether the scoring pass finished or ran out of its own budget.

What is not promised is soft optimality. Proving a weighted optimum needs a bound on remaining cost that this search has no cheap way to compute — and a programme that genuinely needs one wants a solver, which is the next section.

Scale, and where it stops

How far the exact search reaches depends on the shape of a programme rather than its size — sessions that cannot interact are solved separately and concurrently, so 1,200 spread across twenty days lay out in about a second, while 500 all competing for a single day exhaust the default :nodes budget. Density is the variable rather than the total: 200 in one day take about 220 ms and 450 about 1.5 s, and when the budget does run out the first remedy is to raise it — those same 500 place in 2 s given nodes: 200_000.

Past that — a university timetable of thousands of classes, or a month's roster minimising overtime — the answer is a purpose-built constraint solver rather than a bigger search here. The way to use one is to write its answer back through allocate/2: the ledger does not care who computed the layout, and everything downstream keeps working.

There is deliberately no solver mode in this library. One was built against fixpoint, measured against arrange/3, and found slower at every size — so it lives in the test suite rather than the API, where it earns its keep differently: the same programmes are solved a second, independent way and the two answers must agree. See test/support/fixpoint.ex, and the conference case study for the measurements and why the result is less surprising than it looks.

Open hours from a calendar

Availability usually already exists somewhere. from_ical/1 reads RFC 7953 VAVAILABILITY — what a CalDAV server hands you when asked when someone is free:

{:ok, hours} = Agenda.from_ical(vavailability)
{:ok, clinic} = Agenda.open(Agenda.resource("Clinic"), hours)

PRIORITY across overlapping components and each AVAILABLE recurrence resolve against the window you actually ask about, not at import time. VEVENTs in the same document are ignored — those are time taken, and belong in free/2's :busy.

Requires the optional ical dependency.

Documentation

  • Getting started — the one Tempo idea you need, the five nouns, and the whole describe-plan-allocate-cancel loop in five minutes.

  • Sequencing, tracks and affinity — this after that, these may not collide, and keep them together if you can: three kinds of constraint, and why the differences matter more than the syntax.

  • Consultants on customer sites — an IT services business. Skills matching, customer sites as resources, travel between jobs, and why a consultant's day is a track.

  • Meeting rooms and AV equipment — a larger business. Room attributes, booking a room and a projector together, accessibility that cannot be forgotten, and moving a meeting without losing the room.

  • ElixirConf US 2025 — the real programme: 42 talks, three rooms, two days, and the constraint invented data misses. Why a speaker is a resource, not a label.

  • ElixirConf 2027 — two days, keynotes, and parallel tracks. Where planning becomes searching, and where the search stops.

  • Run in Livebook Scheduling ElixirConf — the real ElixirConf US 2025 programme laid out from scratch: 42 talks, three rooms, two days, and the constraint invented data misses.

  • Run in Livebook Timesheets and leave — recording work and absence against one ledger, and reconciling a period against the hours that were owed.

  • Changelog — release history.

Full API documentation is available on HexDocs.

License

The code is licensed under the Apache License 2.0; LICENSE.md gives it.