Build

Time

One declared clock per run, the person's words read by a strategy you arm, tool periods written exactly or refused with a reason — every step recorded.

"Errors since 8" means nothing without a clock: which day, which zone, which eight. An agent that guesses writes a window nobody asked for into a tool call, and the answer looks right. The time layer makes every one of those steps a declared input or a recorded row, so a reader of the run can see what was asked, what the tool was sent, and why.

It has five parts, each opt-in: the run clock, the reader that reads the person's words, the time ask that checks a person's answer, a tool's period forms (how its arguments spell a window), and a dataset's time axis. Runnable examples: examples/features/81-run-clock.ts through 85-time-widen-and-refuse.ts.

The run clock

.time() on the builder arms the layer. The zone is per run — run({ time: { now, zone, window } }), typed RunTime — and the builder's TimeOptions supply a fallback zone (plus the reader, its policy and your messages, below). With no zone anywhere the run still goes ahead, with its zone unknown (zoneSource: 'unknown'). The server's zone is never used, and the person is asked for theirs before any time they wrote is read.

The clock is recorded, never read in secret. Each turn files one ClockRow — a TimeClock (now, nowSource, zone, zoneSource) plus, when a UI set one, a ControlWindow (the UI's time.window, a TimeRange with source: 'control'). TimeRange is half-open: from included, to not. A resume that passes a different time keeps the frozen clock and files a ClockOnResumeRow (what was passed, what was kept). Each dispatched call files a CallRow with its dispatchedAt moment and, when a look-back drifted, the drift.

import { Agent } from 'agentfootprint';

const agent = Agent.create({ provider, model })
  .tool(backupFailures)
  .time({ zone: 'America/Los_Angeles' }) // the fallback; each run's own zone wins
  .build();

await agent.run({ message, time: { now: message.sentAt, zone: session.zone } });
agent.findings();
// [{ kind: 'clock', now, nowSource: 'app', zone, zoneSource: 'run', … },
//  { kind: 'call', dispatchedAt, … }]

Reading the person's words

The library reads a person's message only through a TimeReader you arm with .time({ reader }) — there is no default. A reader is a strategy: it is handed the text and a TimeReadContext (a tokenizer hint — no clock, no zone) and returns a TimeReading, a list of TimeMention values. Each mention carries a verbatim quote (checked against the text) and zone-less parts, TimeParts: a TimeDate as written (10/09/26 is three numbers, the order undecided), a TimeWall clock time with an optional meridiem, or a TimeRelative phrase ("yesterday", "the last 40 minutes").

The English reader accepts “last hour”, “past hour”, “last day” and “past day”, with or without “the”, as a one-unit lookback. A stated zone stays with the phrase. Bare “last week” and “past week” remain unreadable rather than being guessed as an elapsed span; “the last week” keeps its existing meaning.

The library, not the reader, resolves the parts against the clock. Every reading the parts allow becomes a TimeCandidate — a ResolvedWindow (its TimeWindow, the range, and TimeNote entries saying how it was read) tagged with ReadingTags (which date order, which year). A TimePart names one piece a person can say (a date, a wall time, a zone, a duration). Your TimePolicy (dateOrder, year, both 'ask' by default) may only remove candidates, and the removal is recorded. The outcome is a ReadingChoice: the only candidate, the policy's pick, or open with an OpenQuestion for each thing only the person can settle — a date order, a missing am/pm, a DST overlap, a zone abbreviation.

A zone abbreviation on a civil window is asked unless your policy maps it. abbreviations holds one ZoneAbbreviation per spelling your people write: the zone it stands for and the offset its letters spell, as in policy: { abbreviations: { PST: { zone: 'America/Los_Angeles', offset: '-08:00' } } }. When the complete interval boundaries disagree (PST in October is −07:00 by the zone, −08:00 by the letters), both readings are offered in the one confirmation. Equal complete intervals need no extra choice. No abbreviation map ships.

Each message read files one TimeReadingRow per mention (or one row with mentions: 0), stamped with a TimeReaderStamp — the reader's id, version, kind and locale — and the tz database version. A resume never calls the reader again; it reads the rows back.

Agent.create({ provider, model })
  .time({ zone: 'America/Los_Angeles', reader: myReader, policy: { dateOrder: 'MDY' } })
  .build();
// agent.findings() → … { kind: 'time-reading', quote: '10/09/26 8 AM to 8:40 AM',
//   candidates: [...], choice: { by: 'policy', candidate: 0, policy: { dateOrder: 'MDY' } }, … }

The library's English reader

englishTimeReader() is the reader the library ships: a kind: 'rule' tokenizer with no dependency. It reads a small, closed set — ISO dates and instants, numeric dates (the order left open), dates with a named month (11 September, Sept 29 — the year from your year policy when not written), clock times, a range of two (today between 1 pm and 2 pm), a zone written after them, today / yesterday / tomorrow, "last 40 minutes" and the compact last 24h — and files every other time phrase it recognises as ONE unreadable mention quoting the whole phrase. EnglishTimeReaderOptions takes only a locale for the labels of the ask; it never decides a date order.

A duration can name its display zone: last 1 hour in America/New_York keeps the same elapsed instants as last 1 hour, shown in the requested zone. Calendar words such as yesterday use that zone to determine the instants too. A stated abbreviation is resolved by the declared policy. For elapsed spans, an unreadable or unrepresentable display zone leaves the known instants in UTC, quotes the requested label and says it was not converted. Civil windows still ask when the zone can change an endpoint. The run's fallback never silently replaces a stated zone.

A reading only PROPOSES. Nothing typed in chat is filed as the person's words: when a tool that declares a period is about to be called, the reading is offered as a pre-filled, editable confirmation naming its window and its zone, and only the person's answer settles it. Words the reader could not read (yesterday morning, since 8 AM) are asked too — "Which time did you mean by …?", nothing filled in — even when the tool's rule assumes a default: the person wrote a time, so a default never stands in for it. Every answer settles the mention as a TimeAnswerRow (confirmed or edited).

import { Agent, englishTimeReader } from 'agentfootprint';

Agent.create({ provider, model })
  .time({ zone: 'America/Los_Angeles', reader: englishTimeReader() })
  .build();

Correcting the proposed time

The existing time-range field keeps its ID, choices and labels. It becomes optional beside one optional text field for a correction: fill exactly one. The text can name a zone, replacement time words or a complete ISO interval. Zone and time-word corrections produce a new proposal at the original message clock, which still needs confirmation. A full interval is an explicit edit. Unreadable text, both fields or neither stay pending with a reason and examples; ordinary language in the range field still meets its strict ISO validator.

The public TimeReplyRow is the ledger's bounded record of that reply decision. It has kind: 'time-reply', the turn, iteration and mention it belongs to, and a decision of 'range', 'reask' or 'hold' with a closed rule label such as 'range-confirmed', 'zone-corrected' or 'both-filled'. It carries no raw reply, quote or parser output. A hold or reask records that the meaning still needs an answer; it does not prove confirmation or tool execution. The accepted window and its zone are recorded separately in TimeAnswerRow once the person settles them. Raw working proposals stay on the existing protected ask path.

An accepted TimeAnswerRow.corrected keeps the corrected canonical lookback duration, when applicable, without copying raw correction text. An empty object marks a corrected absolute interval. Later calls use this accepted meaning and do not attribute it to the original words. Older rows without the optional object keep their original meaning.

/** Correct the display zone, then confirm the proposed interval before execution. */async function correctionExample(): Promise<void> {  handed.length = 0;  const agent = desk([]);  try {    const first = await agent.run({ message: 'Show activity in the last 1 hour in EST', time });    if (!isInputPause(first)) throw new Error('Expected the time confirmation');    const range = first.awaitingInput.fields.find((field) => field.format === 'time-range');    const correction = first.awaitingInput.fields.find(      (field) => field.id === `${range?.id}-correction`,    );    if (correction === undefined) throw new Error('Expected the optional correction field');    check(range?.labels?.[0]?.includes('not converted') === true, 'the unmapped zone is explicit');    const changed = await agent.resume(first.checkpoint, {      requestId: first.awaitingInput.requestId,      values: { [correction.id]: 'America/New_York' },    });    check(handed.length === 0, 'a correction alone permits no execution');    if (!isInputPause(changed)) throw new Error('Expected the corrected proposal');    const offer = changed.awaitingInput.fields.find((field) => field.format === 'time-range');    if (offer?.enum?.[0] === undefined) throw new Error('Expected the corrected interval');    await agent.resume(changed.checkpoint, {      requestId: changed.awaitingInput.requestId,      values: { [offer.id]: offer.enum[0] },    });    check(handed.length === 1, 'one execution follows confirmation');  } finally {    await agent.shutdown();  }}

What a host's form does with the pair

This ask has two optional fields and an empty missing, so a host's form changes unless the host handles the pair:

  • A host that draws every optional field shows the correction box and marks the range as optional.
  • A host that answers from missing finds nothing to answer and sends values: {}.
  • A one-click confirm that needs exactly one asked field counts two, so a click chooses the reading and waits for the form's send.

An answer with neither value is held: the ask comes back with the reason on the range field, and no tool runs. A field gets three rounds, and an earlier zone question uses one of them. When the rounds run out, the call is refused.

Read the pair with timeReplyPairs(request) from agentfootprint/time, not from the reserved context key. It takes the ask (a TimeReplyRequest) as the run returned it or as JSON off the wire, and returns one TimeReplyPair ({ range, correction }) for each declared pair, or [] for an ask that has none. The door imports nothing from Node, so a web front end can use it. A host's one-click confirm treats the declared sibling as not asked:

/** A host's one-click confirm treats the declared sibling as not asked. */function answersWithOneClick(request: TimeReplyRequest, fieldId: string): boolean {  const siblings = new Set(timeReplyPairs(request).map((pair) => pair.correction));  const asked = request.fields.filter((field) => !siblings.has(field.id));  return asked.length === 1 && asked[0]?.id === fieldId;}async function hostOneClickExample(): Promise<void> {  handed.length = 0;  const agent = desk([]);  try {    const paused = await agent.run({ message: 'Show activity yesterday', time });    if (!isInputPause(paused)) throw new Error('Expected the time confirmation');    // What a browser host holds: the ask as JSON, off the wire.    const ask = JSON.parse(JSON.stringify(paused.awaitingInput)) as typeof paused.awaitingInput;    const range = ask.fields.find((field) => field.format === 'time-range');    if (range?.enum?.[0] === undefined) throw new Error('Expected the proposed interval');    check(ask.fields.length === 2, 'the correction text is declared beside the range');    check(answersWithOneClick(ask, range.id), 'one click on the reading is the whole answer');    await agent.resume(paused.checkpoint, {      requestId: ask.requestId,      values: { [range.id]: range.enum[0] },    });    check(handed.length === 1, 'one execution follows the click');  } finally {    await agent.shutdown();  }}

It throws a TypeError when a stamped binding does not match its fields. An ask the library wrote and JSON carried never does that, so the throw means something changed the ask on its way: a transport that dropped a field, or a front end older than the server. It does not return [], because [] says the ask has no pair, and the server would still read one. Catch it where a bad response must not take the page down, and offer no one-click confirm there. The server judges whatever is sent.

A call with only a start or stop

Completing a tool's missing endpoint does not confirm what the person's time words mean. The library first completes that ordinary argument, preserving the supplied endpoint. It then asks the native time question before anything in the batch runs. A default or kept answer can complete the tool arguments, but the time confirmation is still owed.

The selected tool interval and the confirmed person interval remain separate. For example, a tool completed to 13:00–15:00 keeps those arguments when the person confirms 14:00–16:00. The record says model-chosen and includes both windows. Only equality of the whole normalized interval permits value binding; an ordinary endpoint answer retains its ordinary argument provenance. An all-missing sibling may use the confirmed window, while a complete baseline or drilldown interval keeps its own arguments. Invalid answers and cancellation permit no waiting collector to execute.

/** Ordinary endpoint input and a person's confirmed window remain two facts. */async function partialBoundsExample(): Promise<void> {  const selected: unknown[] = [];  const series = defineTool({    name: 'series',    description: 'Read a synthetic interval.',    inputSchema: {      type: 'object',      required: ['start', 'stop'],      properties: { start: { type: 'string' }, stop: { type: 'string' } },    },    askOrAssume: { start: { ask: 'From when?' }, stop: { ask: 'Until when?' } },    period: {      forms: [        {          kind: 'bounds',          from: { argument: 'start', as: 'iso' },          to: { argument: 'stop', as: 'iso', edge: 'exclusive' },        },      ],    },    execute: (args) => {      selected.push(args);      return 'synthetic result';    },  });  let calls = 0;  const agent = Agent.create({    model: 'mock',    provider: mock({      respond: () =>        ++calls === 1          ? {              content: '',              toolCalls: [                { id: 'partial', name: 'series', args: { start: '2026-10-09T13:00:00Z' } },              ],            }          : { content: 'Done.' },    }),  })    .tool(series)    .time({ zone: 'UTC', reader: englishTimeReader() })    .build();  try {    const ordinary = await agent.run({      message: 'Read the last 2 hours',      time: { now: '2026-10-09T16:00:00Z' },    });    if (!isInputPause(ordinary)) throw new Error('Expected the missing stop');    const meaning = await agent.resume(ordinary.checkpoint, {      requestId: ordinary.awaitingInput.requestId,      values: { f1: '2026-10-09T15:00:00Z' },    });    check(selected.length === 0, 'no execution before time confirmation');    if (!isInputPause(meaning)) throw new Error('Expected time confirmation');    await agent.resume(meaning.checkpoint, {      requestId: meaning.awaitingInput.requestId,      values: { f1: '2026-10-09T14:00:00Z/2026-10-09T16:00:00Z' },    });    check(      JSON.stringify(selected) ===        JSON.stringify([{ start: '2026-10-09T13:00:00Z', stop: '2026-10-09T15:00:00Z' }]),      'the selected tool interval survives confirmation',    );    const effective = agent      .findings()      ?.filter((row) => row.kind === 'call-window' && row.toolCallId === 'partial')      .at(-1);    check(      effective?.kind === 'call-window' && effective.how === 'model-chosen',      'the differing scopes remain visible',    );  } finally {    await agent.shutdown();  }}

Saved pauses: bind a new native confirmation pause to its exact writer runtime/artifact and agent configuration. Resume it with that same configuration. An older reader can ignore its confirmation-only role and replace the selected endpoints, so downgrade resumption is unsafe and unsupported. Drain or explicitly abandon outstanding pauses before changing their runtime; hosts must enforce compatible routing during restarts and upgrades.

The time ask

A requestInput field with a format is a time field. TimeFormat is one of 'instant', 'time-range' or 'zone'. The answer is judged at the resume door before your app sees it: an instant needs an offset, a range needs from before to, a zone must be an IANA name, and under a known zone no wall time the clocks skip. A refused answer is not taken — the ask comes back with refused: { answer, reason } and repeat: { count }, and nothing runs. The refusal is a TimeAnswerProblem code; the sentence comes from the catalog.

The catalog is data. defaultTimeAskMessages holds one sentence per TimeAskMessageKey; a TimeAskMessages value is a whole catalog, and .time({ messages }) overrides any keys you choose.

Over MCP the ask travels as an elicitation: elicitationOf turns the paused ask into an ElicitationRequest (one ElicitationProperty per way to answer each open field — MCP has no range, so a range is two date-time properties), and answerFromElicitation turns the host's reply back into an input response that agent.resume judges like any other answer.

execute: () => requestInput({
  id: 'window',
  question: 'Which window should the search cover?',
  fields: [{ id: 'window', type: 'string', format: 'time-range' }],
}),
// agent.resume(cp, { requestId, values: { window: '…T08:40-07:00/…T08:00-07:00' } })
// → paused again: awaitingInput.refused = { answer, reason: 'The start … is not before the end ….' }

A tool's period

A tool declares every shape its period takes with defineTool({ period }). A PeriodForm is one shape: two arguments (bounds), one joined argument, an object argument, a single day, or a look-back. Each Bound names its argument and a BoundAs spelling — 'iso', 'epoch-ms', 'epoch-s', 'date' or 'wall' — and a to bound says whether its edge is inclusive or exclusive. A wall or date form reads its zone from a ZoneArgument. The shorthand { argument, spelling } takes a PeriodSpelling ('lookback', 'signed-lookback', 'iso-range', 'wall-range').

Beside the forms a tool declares PeriodFacts about its source — facts, never policy: direction (a PeriodDirection: 'past', 'future' or 'any'), retention, maxRange, granularity and filtersToAsked.

What the library then does, under .time():

  • Fill exactly. The model left every period argument out and the turn holds exactly one window of the person's (a PersonWindow): the window is written into the first form that holds it exactly, and the arguments are filed as the person's.
  • Widen, and say so. No form holds it exactly: the first form that reads more (a day, or the covering look-back) is used, and the row records what it adds.
  • Record the model's window. A window the model wrote runs as sent — bound to the person's by quote or value, or recorded model-chosen.
  • Refuse before dispatch. A window outside the tool's direction, wholly older than its retention, wider than its maxRange, spanning days for a day tool, or a wall time the zone skips: the tool never runs, and the model reads why.

A window the person set in your UI — a run's time.window, from a brushed chart or a range picker — is named on the model's late line with each tool's values ("The window the person set in the app's time control is …"), so your app writes no prompt text for it. When a form needs its own cap, give it maxRange: { kind: 'bounds', …, maxRange: '24h' } caps the bounds at a day while a look-back form beside it reads any length; a window is refused over-max-range only when no form can read it.

One CallWindowRow per call says which. Two more rows complete the record: a TimeDerivedRow when a judged answer states values the library spelled from a reading, and a SourceClockRow when a dataset declares the wall-clock zone its values are in. The tool is handed the call's time as ctx.time, a TimeContext (the asked range, the zone, the frozen now and the dispatch moment) — over MCP it rides the tools/call request's _meta.agentfootprint.time.

import { defineTool } from 'agentfootprint';

defineTool({
  name: 'client_activity',
  inputSchema: {
    type: 'object',
    properties: { start_time: { type: 'integer' }, end_time: { type: 'integer' } },
  },
  period: {
    forms: [{
      kind: 'bounds',
      from: { argument: 'start_time', as: 'epoch-ms' },
      to: { argument: 'end_time', as: 'epoch-ms', edge: 'exclusive' },
    }],
    direction: 'past',
    retention: '30d',
  },
  execute: (args, ctx) => query(args, ctx.time?.asked),
});

The same answer outside a run

Sometimes an app needs that answer before any run: a screen that previews what a tool would read for the person's window, or a call the app builds itself. Ask the library's own conversion for it rather than keeping a copy. A copy drifts the first time the fill's rules change, and the preview then shows a read the run will not send. The conversions ship on their own door, agentfootprint/time. They are part of the layer's run-time half, so the main barrel keeps them off every plain agent's import graph.

Ask convertForTool. It is the question the run asks of each tool, and it takes the tool's own period declaration:

  • the window, as a WindowToConvert (its range, and its lookback when it was said as one);
  • the tool's forms, sugarForms(period): the forms it declares, else the ones its argument + spelling (or accepts) stand for. sugarForms takes any PeriodShapes, so a whole period will do;
  • the tool's facts, which is the period itself (direction, retention, maxRange, granularity);
  • a ConvertContext: now, the window's zone, and the tool's step as granularityMs. Build that step with granularityMsOf(period), which reads granularity and falls back to one minute the way the run does.

Its answer is a ToolConversion. A tool that can read the window gives { conversion }: the form's index and the argument values to send, plus sent (the range the tool reads) and extra (the parts nobody asked for) when it reads more than was asked. A window that starts before the source's oldest data and ends after it also carries partlyBeyondRetention: true. A tool that cannot read it gives { refused } with a TimeRefusal code. It is a fact the window breaks ('time-past', 'time-future', 'beyond-retention', 'over-max-range'), 'multi-day' (a day-only tool and a window over more than one day), or 'no-form-holds' (no form can read it, even wider: a look-back ends at now, and a covering look-back longer than maxRange is not read). The run applies these rules in this order, and so does convertForTool. It is the same function the time ask and the fill call; examples/features/85-time-widen-and-refuse.ts checks it against a real run, the refusal included.

import { convertForTool, granularityMsOf, sugarForms } from 'agentfootprint/time';

// The tool's own declaration, the `period` passed to defineTool.
const period = { argument: 'window', spelling: 'lookback', direction: 'past', maxRange: '24h' } as const;
const clock = { now: '2026-10-09T15:40:00Z', zone: 'America/Los_Angeles', granularityMs: granularityMsOf(period) };
const yesterday = { from: '2026-10-08T00:00:00-07:00', to: '2026-10-09T00:00:00-07:00' };

convertForTool({ range: yesterday }, sugarForms(period), period, clock);
// { refused: 'no-form-holds' }: reaching 8 Oct from now takes a look-back wider than 24h
convertForTool({ range: yesterday }, sugarForms(period), { direction: 'past' }, clock);
// { conversion: { form: 0, values: { window: '1960m' },
//     sent:  { from: '2026-10-08T07:00:00Z', to: '2026-10-09T15:40:00.001Z' },
//     extra: [{ from: '2026-10-09T07:00:00Z', to: '2026-10-09T15:40:00.001Z' }] } }

The door also carries the steps convertForTool takes, for a caller that needs one alone. They answer only their own step:

  • convertExact returns the first form that holds the window exactly, as a Conversion, or undefined.
  • convertWidened returns the first form that holds it by reading more, as a WidenedConversion. Its optional fourth argument is the tool's widest read in milliseconds, widestMsOf(period); leave it out and a read wider than maxRange is not skipped. It does not look for an exact form first, so ask it only after convertExact finds nothing: with forms [day, bounds] and a one-hour window, convertExact answers with the bounds form and convertWidened asked alone answers with the day.
  • periodFactProblem returns which declared fact a range breaks, as a PeriodFactProblem code, or undefined. It checks the facts only. A range it cannot read as two instants ('garbage') breaks no fact by this check; convertForTool refuses that range 'no-form-holds'.

Two helpers write and check time the way the run does. presentRange(range, presentation, grain?) is the label the time ask and the late line use: a Presentation (the zone, and an optional locale) and a Grain give the end as said, the zone always named — presentRange(range, { zone: 'America/Los_Angeles' }, 'minute') → '2026-10-09 08:00–08:40 America/Los_Angeles (UTC-07:00)'. isZoneName(value) is the run's zone check: it narrows to a ZoneName only for an IANA name and refuses PST or a bare offset, so a session zone can be checked before it is passed as time.zone.

All of them are pure functions over the clock you pass. They need no run, write no record and do not need .time(). Two inputs are the caller's to get right. A now that is not an instant gives no look-back answer: a look-back tool then gets 'no-form-holds' from convertForTool. A day or wall form asked with a zone that is not an IANA zone name throws.

A dataset's time axis

A dataset declares which column is time with a DatasetTimeAxis. normaliseInstants is the read-side view over its rows: it never writes them and returns a NormalisedAxis — AxisPoint values (row and UTC instant), sorted, spelled at one AxisPrecision, with AxisCounts for every value it could not place (naive, DST-ambiguous, in a DST gap, unreadable, missing). A value with no offset under an axis with no zone is never read as UTC: it is counted, or — with NormaliseOptions { naive: 'refuse' } (a NaiveValues choice) — the whole view is refused. In the hour the clocks go back, a doubled wall time is placed by row order and noted with an AxisOverlapNote.

import { normaliseInstants } from 'agentfootprint';

normaliseInstants([{ t: '2026-09-14T10:00:00' }], { column: 't', unit: 'iso' });
// { status: 'naive-values', count: 1, points: [] } — never read as UTC

What is not here yet

The English reader reads English digits only: words for numbers ("two hours"), named months, week days and parts of the day read as unreadable. A turn with no time words does not carry the last window yet. The result checks read only what the record holds: a call whose tool declares no period forms has no asked range, so only its declared read is checked against retention.

On this page