Humanize Fireworks AI Output in Perl: An On-Call Handoff Note Linter + 5 Free Tools (2026)
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
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:
- No warm-up. "Sure!", "Certainly", "Here's" at the start fails.
- No reassurance filler. "Rest assured", "no need to worry", seamless, robust, proactively. Reassurance without evidence is noise.
- Hedge budget of two. Past two "it seems / likely / probably", nobody can tell what you verified.
-
Every clock time needs a zone.
23:40fails;23:40 UTCand15:10 +02:00pass. A range like03:12-03:20 UTCcounts once. - No relative days. "Last night" means something else to a reader six hours and one timezone away.
-
An Open line must exist.
Open:,Watch:,UnresolvedorNext shiftat the start of a line. - 180 words, max. A handoff is not a postmortem.
- 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 withAuthorization: Bearer <API_KEY>. -
modelis required. The reference's example isaccounts/fireworks/models/kimi-k2-instruct-0905, which is my default; override it withFIREWORKS_MODEL. -
messagescarries the conversation, each entry with aroleandcontent. -
max_tokensis optional; by default Fireworks lowers it to fit the context window rather than erroring.max_completion_tokensis 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" % len(payload))
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(payload)
def log_message(self, fmt, *args):
pass
if __name__ == "__main__":
HTTPServer(("127.0.0.1", PORT), FakeFireworks).serve_forever()
Running it
# HTTPS needs IO::Socket::SSL + Net::SSLeay (apt: libio-socket-ssl-perl)
perl handoff_lint.pl samples/clean.txt
perl handoff_lint.pl --json - < note.txt
export FIREWORKS_API_KEY=...
perl handoff_lint.pl --draft "payments queue backed up 23:40-00:25 UTC, rolled back deploy"
python3 mock_fireworks.py robotic 8811 &
FIREWORKS_API_KEY=test-key FIREWORKS_BASE_URL=http://127.0.0.1:8811/inference \
perl handoff_lint.pl --draft "cache evictions spiked"
prove -v t/lint.t
Test results (2026-11-08, Perl 5.40.1, HTTP::Tiny 0.088, IO::Socket::SSL 2.099, Linux x86_64)
The box had Perl but not the TLS modules, and the distro mirror was unreachable, so I built Net::SSLeay 1.96 and IO::Socket::SSL 2.099 from their CPAN tarballs (sha256 checked against MetaCPAN). run_tests.sh printed:
-
prove -v t/lint.tβ 12 tests, all pass -
samples/robotic.txtβ 1 (preamble, filler, 4 hedges, a bare clock time, "last night", no Open line, sign-off) -
samples/clean.txtβ 0 -
--json samples/long.txtβ 1 (700 words against a cap of 180) -
-(same clean note on stdin) β 0 - a path that doesn't exist β 2
- called with nothing β 2 (prints usage)
- mock serving the robotic answer β 1
- mock serving the clean answer β 0
- mock answer with
03:12and03:20but no zone β 1 - mock replying HTTP 401 β 2
- mock replying with an empty message β 2
- nothing listening on the port β 2
-
FIREWORKS_API_KEYunset β 2 -
https://self-signed.badssl.comas the base URL β 2 (certificate verify failed), so verification is really on - real
api.fireworks.aiwith a made-up key β 2, but as HTTP 404 "Model not found, inaccessible, and/or not deployed", not a 401
That last one surprised me. With a bad key the chat endpoint answered 404 about the model, while GET /inference/v1/models with the same key gave a plain 401, "The API key you provided is invalid." Seeing "Model not found" on a fresh setup? Check the key first.
All five mock calls logged bearer=present, application/json, the default model, max_tokens=350 and roles=system,user. No funded key was used, so nothing was billed.
5 free tools for the sentences the linter rejects
1. Vale
Open-source command-line prose linter driven by your own style rules. Strength: team vocabulary checks in CI next to this script. Limit: it only finds words you listed in advance.
2. LanguageTool
Free grammar checking in the browser. Strength: helps teammates writing in a second language. Limit: a grammatical note can still hide the timezone.
3. UnAIMyText
UnAIMyText takes prose that reads as machine-written and returns a more human version, free and without signing up. I paste only the summary sentence, never timestamps, hostnames, ticket numbers or the Open line. Strength: "Rest assured the payments queue appears to be stable following proactive remediation" came back as "Payments queue is back to normal since the rollback." Limit: no public API, so it sits outside the script; I run handoff_lint.pl on the result again so the zone and Open checks still pass.
4. Hemingway Editor
Free web editor that marks dense sentences. Strength: splitting the run-on that explains cause, fix and follow-up at once. Limit: knows nothing about on-call.
5. Grammarly
Free tier catches typos. Strength: a misspelled service name costs someone a grep. Limit: its tone suggestions lean toward the reassuring voice this linter removes.
What I never send through a humanizer
Timestamps, hostnames, ticket numbers, error strings, thresholds and who to page. Those get copied straight into a terminal. Only prose goes through a rewriting tool, and the result goes back through handoff_lint.pl.
FAQ
Which free AI humanizer works best on Fireworks AI output?
For handoff notes: (1) Vale for team vocabulary, (2) LanguageTool for grammar, (3) UnAIMyText for the summary sentence once facts and times are right but the tone isn't. I use UnAIMyText for that pass because it's free and asks for no account.
Why Perl?
It's already on most boxes that page us, and text is what it was built for.
Verdict
Vale, LanguageTool, Hemingway and Grammarly each catch part of a bad handoff. For teams generating on-call notes with Fireworks AI, pair this linter with UnAIMyText, my free #3: keep the times, hosts and tickets in your own hands, humanize the summary line, and let a short Perl script insist on a timezone and an Open line before anyone goes to sleep.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.