Tested 2026-11-08. Disclosure before the code: I help build UnAIMyText, which is why it's #3 in the tools list. Copy the Perl freely.

Our on-call engineers write a handoff note for whoever takes the pager next. Someone wired that step to an open-weight model on Fireworks AI: paste raw shift notes, get a tidy summary.

Tidy, and useless at 3 a.m. The first one I got opened with "Rest assured, everything is under control," called the payments queue "likely stable," and mentioned a spike at 23:40 in no particular timezone. Our team spans three.

So I wrote a small Perl script. Give it shift notes and it asks Fireworks for a handoff, then grades it. Give it a file or stdin and it only grades.

What a handoff note has to survive

The reader just woke up and needs three things: what happened, what you checked, what is still open. Each rule protects one of them:

  1. No warm-up. "Sure!", "Certainly", "Here's" at the start fails.
  2. No reassurance filler. "Rest assured", "no need to worry", seamless, robust, proactively. Reassurance without evidence is noise.
  3. Hedge budget of two. Past two "it seems / likely / probably", nobody can tell what you verified.
  4. Every clock time needs a zone. 23:40 fails; 23:40 UTC and 15:10 +02:00 pass. A range like 03:12-03:20 UTC counts once.
  5. No relative days. "Last night" means something else to a reader six hours and one timezone away.
  6. An Open line must exist. Open:, Watch:, Unresolved or Next shift at the start of a line.
  7. 180 words, max. A handoff is not a postmortem.
  8. No chatbot sign-off. "Let me know if you have any questions" at the end of a pager handoff is a tell.

Exit 0 means hand it over, 1 means rewrite it, 2 means the request or input failed. --json is for CI.

What the Fireworks reference says

I built against Fireworks AI's chat completions API reference. The parts this script relies on:

  • POST https://api.fireworks.ai/inference/v1/chat/completions, authenticated with Authorization: Bearer <API_KEY>.
  • model is required. The reference's example is accounts/fireworks/models/kimi-k2-instruct-0905, which is my default; override it with FIREWORKS_MODEL.
  • messages carries the conversation, each entry with a role and content.
  • max_tokens is optional; by default Fireworks lowers it to fit the context window rather than erroring. max_completion_tokens is an alias, and you can't send both.
  • The text comes back in choices[0].message.content.

FIREWORKS_BASE_URL lets the tests point at a local mock.

The script

HTTP::Tiny and JSON::PP ship with Perl. HTTPS also needs IO::Socket::SSL and Net::SSLeay (Debian/Ubuntu: libio-socket-ssl-perl). verify_SSL has defaulted to on since HTTP::Tiny 0.083; I set it explicitly so older installs behave the same.

#!/usr/bin/env perl # handoff_lint.pl: ask Fireworks AI for an on-call handoff note, or lint one you already have. # perl handoff_lint.pl --draft "db failover 02:10 UTC, replica lag ok, cert renew pending" # perl handoff_lint.pl samples/robotic.txt # perl handoff_lint.pl --json - < note.txt # Exit codes: 0 = hand it over, 1 = rewrite before the next shift reads it, 2 = request or input failed. # Core Perl (HTTP::Tiny, JSON::PP); HTTPS additionally needs IO::Socket::SSL + Net::SSLeay. use strict; use warnings; use utf8; use HTTP::Tiny; use JSON::PP; my $MAX_WORDS = 180; my $MAX_HEDGES = 2; my $PREAMBLE = qr/\A\s*(?:sure|certainly|absolutely|of course|great question|here(?:'s| is| are))\b/i; my $FILLER = qr/\b(?:rest assured|everything is under control|no need to worry|seamless(?:ly)?|robust|proactive(?:ly)?|leverag(?:e|ed|ing)|comprehensive|holistic|as an ai|delve)\b/i; my $HEDGE = qr/\b(?:it seems|it appears|appears to be|might potentially|could possibly|likely|presumably|probably)\b/i; my $RELDAY = qr/\b(?:last night|earlier today|this morning|yesterday|a while ago|recently)\b/i; my $OPENLINE = qr/^\s*(?:[-*]\s*)?(?:\*\*)?(?:open|still open|watch|unresolved|next shift)\b/im; my $SIGNOFF = qr/(?:let me know if you have any questions|hope this helps|feel free to reach out|best regards)\W*\z/i; my $ZONE = qr/\s?(?:UTC|GMT|Z\b|[ECMP][SD]?T\b|[+-]\d\d:?\d\d)/; my $HHMM = qr/(?:[01]?\d|2[0-3]):[0-5]\d(?:\s?[ap]\.?m\.?)?/i; # a time or a range ("03:12-03:20", "23:40 to 00:25"); the zone may follow the whole span my $CLOCK = qr/(?<![+\-\d:])$HHMM(?:\s?(?:-|–|to)\s?$HHMM)?/i; sub lint { my ($text) = @_; my $t = $text // ''; $t =~ s/\A\s+|\s+\z//g; return ('empty note') if $t eq ''; my @found; push @found, 'model preamble (Sure / Here is / Certainly)' if $t =~ $PREAMBLE; push @found, 'reassurance or marketing filler' if $t =~ $FILLER; my $hedges = () = $t =~ /$HEDGE/g; push @found, "$hedges hedges (say what you checked instead)" if $hedges > $MAX_HEDGES; my $bare = 0; while ($t =~ /($CLOCK)/g) { my $after = substr($t, pos($t), 8); $bare++ unless $after =~ /\A$ZONE/; } push @found, "$bare clock time(s) with no timezone" if $bare; push @found, 'relative time words (last night / earlier today)' if $t =~ $RELDAY; push @found, 'no Open / Watch line for the next shift' unless $t =~ $OPENLINE; my $words = () = $t =~ /\S+/g; push @found, "$words words (keep it under $MAX_WORDS)" if $words > $MAX_WORDS; push @found, 'chatbot sign-off at the end' if $t =~ $SIGNOFF; return @found; } sub give_up { print STDERR "handoff_lint: $_[0]\n"; exit 2; } sub draft { my ($notes) = @_; my $key = $ENV{FIREWORKS_API_KEY} or give_up('FIREWORKS_API_KEY is not set'); my $base = $ENV{FIREWORKS_BASE_URL} || 'https://api.fireworks.ai/inference'; my $model = $ENV{FIREWORKS_MODEL} || 'accounts/fireworks/models/kimi-k2-instruct-0905'; $base =~ s{/+\z}{}; my $payload = { model => $model, max_tokens => 350, messages => [ { role => 'system', content => 'You write on-call handoff notes for the engineer starting the next shift. ' . 'Plain text, under 150 words. Every clock time gets a timezone. ' . 'End with an "Open:" line listing what is still unresolved. No greeting, no sign-off.' }, { role => 'user', content => "Shift notes: $notes" }, ], }; my $http = HTTP::Tiny->new(timeout => 30, verify_SSL => 1, agent => 'handoff-lint/1.0'); my $res = $http->post("$base/v1/chat/completions", { headers => { 'Authorization' => "Bearer $key", 'Content-Type' => 'application/json' }, content => encode_json($payload), }); if ($res->{status} == 599) { my $why = $res->{content} // 'unknown'; $why =~ s/\s+/ /g; give_up('request failed: ' . substr($why, 0, 160)); } give_up("HTTP $res->{status}: " . substr($res->{content} // '', 0, 200)) unless $res->{success}; my $data = eval { decode_json($res->{content}) } or give_up('response was not JSON'); my $text = eval { $data->{choices}[0]{message}{content} }; give_up('no choices[0].message.content in the response') unless defined $text && $text =~ /\S/; return $text; } sub main { my @args = @_; my $json = grep { $_ eq '--json' } @args; @args = grep { $_ ne '--json' } @args; my ($source, $text); if (@args >= 2 && $args[0] eq '--draft') { ($source, $text) = ('fireworks', draft($args[1])); } elsif (@args && $args[0] eq '-') { ($source, $text) = ('stdin', do { local $/; <STDIN> }); } elsif (@args) { open(my $fh, '<:encoding(UTF-8)', $args[0]) or give_up("cannot read $args[0]: $!"); ($source, $text) = ($args[0], do { local $/; <$fh> }); } else { give_up('usage: perl handoff_lint.pl [--json] (--draft "notes" | file | -)'); } my @problems = lint($text); binmode(STDOUT, ':encoding(UTF-8)'); if ($json) { print JSON::PP->new->canonical->encode( { source => $source, ok => (@problems ? JSON::PP::false : JSON::PP::true), problems => \@problems }), "\n"; } else { print "$text\n---\n" if $source eq 'fireworks'; print @problems ? join('', map { "$source: $_\n" } @problems) : "ok: $source\n"; } exit(@problems ? 1 : 0); } main(@ARGV) unless $ENV{HANDOFF_LINT_NO_MAIN}; 1; 

The clock check peeks at the characters after each time for a zone. The lookbehind keeps +02:00 from counting as a second, zoneless time; my first test run caught that, plus a peek window one character too short.

The tests

Test::More loads the script with HANDOFF_LINT_NO_MAIN=1 so main doesn't run, then calls lint directly.

use strict; use warnings; use Test::More; BEGIN { $ENV{HANDOFF_LINT_NO_MAIN} = 1 } require './handoff_lint.pl'; my $clean = "Primary DB failed over to replica-2 at 02:10 UTC; writes back at 02:14 UTC.\n" . "Checked: replication lag under 1s since 02:30 UTC.\n" . "Open: TLS cert on api-gw expires Fri 18:00 UTC, renewal PR #812 waiting on review."; is_deeply([ main::lint($clean) ], [], 'clean note passes'); my @p = main::lint("Sure! Here is the handoff. Rest assured everything is under control."); ok((grep { /preamble/ } @p), 'preamble caught'); ok((grep { /filler/ } @p), 'filler caught'); ok((grep { /Open/ } @p), 'missing Open line caught'); @p = main::lint("Deploy rolled back at 14:05 and again at 3:40 pm.\nOpen: none"); ok((grep { /2 clock time/ } @p), 'two bare clock times counted'); @p = main::lint("Rollback at 14:05 UTC, retry at 15:10 +02:00.\nOpen: none"); is_deeply(\@p, [], 'zoned times pass'); @p = main::lint("It seems the queue is likely fine. It appears probably stable.\nOpen: none"); ok((grep { /hedges/ } @p), 'hedge stack caught'); @p = main::lint("Disk alert fired last night, cleared.\nOpen: none\nLet me know if you have any questions!"); ok((grep { /relative time/ } @p), 'relative day words caught'); ok((grep { /sign-off/ } @p), 'sign-off caught'); @p = main::lint(join(' ', ('word') x 200) . "\nOpen: none"); ok((grep { /201 words|200 words|words \(keep/ } @p), 'length cap'); @p = main::lint("Queue lag 03:12-03:20 UTC, again 23:40 to 00:25 UTC.\nOpen: none"); is_deeply(\@p, [], 'zoned ranges pass'); is_deeply([ main::lint(" ") ], ['empty note'], 'empty note'); done_testing; 

A stand-in for Fireworks

One canned completion per mode, plus a log line per request showing what the client actually sent.

"""Stand-in for the Fireworks chat completions endpoint. Python stdlib only. usage: python3 mock_fireworks.py MODE PORT (MODE: robotic | clean | nozone | error | empty) Each request appends one line to mock.log so the test run can show what the client sent.""" import json import sys from http.server import BaseHTTPRequestHandler, HTTPServer MODE, PORT = sys.argv[1], int(sys.argv[2]) ANSWERS = { "robotic": "Certainly! Rest assured the cache cluster is stable. It seems the evictions were " "likely transient and probably resolved earlier today.\nHope this helps!", "clean": "Cache cluster evictions spiked 03:12-03:20 UTC after a key-size regression.\n" "Rolled back build 9921 at 03:24 UTC; hit rate back to 97%.\n" "Open: add a key-size alert (ticket OPS-318).", "nozone": "Cache evictions spiked at 03:12 and settled by 03:20 after the rollback.\n" "Open: key-size alert, ticket OPS-318.", "empty": "", } class FakeFireworks(BaseHTTPRequestHandler): def do_POST(self): size = self.headers.get("Content-Length", "0") raw_body = self.rfile.read(int(size)) req = json.loads(raw_body or b"{}") messages = req.get("messages") or [] auth = self.headers.get("Authorization", "") record = {"path": self.path, "bearer": "present" if auth.startswith("Bearer ") else "missing", "type": self.headers.get("Content-Type")} for key in ("model", "max_tokens"): record[key] = req.get(key) record["roles"] = ",".join(str(msg.get("role")) for msg in messages) with open("mock.log", "a") as log: log.write(" ".join("%s=%s" % kv for kv in record.items()) + "\n") if self.path != "/inference/v1/chat/completions": self.answer(404, {"error": {"message": "unknown path " + self.path}}) elif MODE == "error": self.answer(401, {"error": {"object": "error", "type": "invalid_request_error", "message": "The API key you provided is invalid."}}) else: reply = {"content": ANSWERS[MODE], "role": "assistant"} self.answer(200, {"object": "chat.completion", "id": "mock-fw-1", "model": req.get("model"), "choices": [{"message": reply, "finish_reason": "stop", "index": 0}]}) def answer(self, status, doc): payload = json.dumps(doc).encode("utf-8") self.send_response(status) self.send_header("Content-Length", "%d" Source:https://dev.to/unaimytext_app/humanize-fireworks-ai-output-in-perl-an-on-call-handoff-note-linter-5-free-tools-2026-188n

Related posts