Building voice-enabled AI assistants

An anime styling of me by ChatGPT
An anime styling of me by ChatGPT

I’m Brian Mathiyakom, a technologist based out of the San Francisco Bay Area. I have about two decades of experience building platforms and teams.

I have contributed to software for a diverse set of domains:

My career has taken me through both 2-person startups up to large enterprises where I served Engineering teams as an engineer and manager.

I even briefly ran a consulting business that focused on technical due-diligence, 0-to-1 product development, and early core team hiring for Product and Engineering.

In 2025, as AI transforms software, I’m creating voice assistants capable of completing tasks to help businesses automate their daily operations, save money, and increase efficiency.

I am also available as a consultant and leadership coach for technical due-diligence, custom software development, and mentorship for engineering managers.

Outside of work, I’m a Muay Thai student and former musician. I love wine, coffee, and mechanical keyboards.

Writing (on Medium)

Finding gold in Github Issues; a long-time Zed and virtual env bug gets fixed

What did I want fixed? I use Zed as my daily driver for code editing. My current projects are in python where I use python’s virtual environments (venv) to separate dependencies between projects (even within modules in monorepos). Zed wasn’t able to handle static type checking (via pyright) in directories that had more than one venv. TLDR; here’s the fix if you’re dealing with this issue: Make sure you have Zed v0.190.4+ installed. If this doesn’t work, then trying adding venv to the pyproject config: # pyproject.toml [tool.pyright] venvPath = "." venv = ".venv" If this doesn’t work, then reinstalling pyright. If you had been using Zed for awhile, it maybe to possible that your pyright installation is broken (for reasons I still don’t understand). Delete it and Zed will reinstall a new one on app restart: # MacOS rm -rf ~/Library/Application\ Support/Zed/languages/pyright/ After the v0.190.4+ release, the “reinstalling pyright” route was the missing solution for me. Shoutout to @ikheifets for the workaround! The journey to getting venv subdirectory detection working for me When I had began working on Supportline (voice-enabled AI assistants), the project had two venvs. One for the main codebase and another for the Dagger module: . ├──.dagger | ├── .venv ├── api ├── bot ├── common ├── dagger.json ├── debug ├── prompts ├── pyproject.toml └── .venv The .venv at the root of the project stored the dependencies for the production code, while the .dagger/.venv stored the dependencies required to build and deploy the container images for the project. Since Zed couldn’t handle multiple venv instances. If I had the project opened in Zed, typechecking would be broken in any of the python files under .dagger/. To workaround this problem, opening the project and the dagger directory in two different Zed windows worked. Zed only detected .venv at the root of whatever directory you had opened. “I’m not the only one” There were plenty (1, 2, 3, 4) of Github issues about this problem. It was hanging around the meta-issue issue for Python support in Zed for awhile. Fortunately, Zed contributors care deeply about making Zed work well across programming languages. The first fix for this issue was merged in April 2025. Unfortunately, that fix met a few regressions and was reverted 🥲. A revised PR was merged (thank you @sudoish) in June 2025. “But, it still doesn’t work on my machine” The revised PR was accepted and released with Zed v0.190.4. I downloaded that build but my setup was still broken. After combing through the Zed Discord, I found my gold 🏅. Instead of focusing on venv issues, I wandered through the pyright issues people were having and stumbled upon this one: pyright is being weird (vague yet accurate title 😅). A comment in that issue suggested that local pyright install may be the problem. I deleted my Zed’s local pyright and upon app restart, Zed downloaded the latest version and everything worked! Venv’s detected and pyright started typechecking each subproject correctly.

Navigating OAuth 2.0 in a terminal UI: building a Spotify client with Go

I’ve been working on a Spotify client app. Why? I saw a coding challenge to build one, so I thought, why not, it’ll be fun. And, I can make it a terminal UI. I have taken an interested in terminal UI apps recently. I wrote a resume generator in Charm and will also use Charm (and Go) for this Spotify app. Generating custom resumes for job applications using Terminal UIs Spotify requires that all third-party applications authorize via OAuth 2.0 to gain access to user data. The user data in this case is my Spotify account: playlists, recently played, currently playing, etc. The problem is that OAuth typically requires user consent by having them click “Authorize” on a web page hosted by Spotify. It also requires my app to accept an HTTP redirect for the authorization code after the user consents. The following diagram illustrates the overall sequence for a web app: .----. .------. .-------. |User| |WebApp| |Spotify| '----' '------' '-------' | | | |visits 3rd party app| | |------------------->| | | | | | |creates code challenge and redirects user to Spotify consent URL| | |--------------------------------------------------------------->| | | | | | accepts authorization request | |------------------------------------------------------------------------------------>| | | | | | redirects user to Webapp authCode page | | |<---------------------------------------------------------------| | | | | | uses auth code to fetch access token | | |--------------------------------------------------------------->| | | | | | returns new access token | | |<---------------------------------------------------------------| | | | | | fetches user data via access token | | |--------------------------------------------------------------->| | | | | displays user data | | |<-------------------| | .----. .------. .-------. |User| |WebApp| |Spotify| '----' '------' '-------' This is a straight-forward integration for a third-party web app. Spotify has a pretty good tutorial on how to do this. But my app is a terminal app. How is this supposed to work? OAuth for a Terminal UI There’s two problems to solve: Getting the user to click the Authorize (consent) button on the Spotify URL Getting the authorization code from the Spotify redirect into the app The first problem is solved by opening the Spotify consent URL through the user’s default browser. Since this is a client application, one may need to support a way to open the user’s browser across multiple OS’s (MacOS, Linux, Windows, etc). On MacOS, for example, I’m doing the following: exec.Command("open", consentUrl).Start() Obtaining the consent URL involves making an HTTP API request to Spotify using a PKCE authorization flow. Spotify recommends using PKCE when “implementing authorization in a mobile app, single page web apps, or any other type of application where the client secret can’t be safely stored.” My terminal UI app falls under this category. To implement this via PKCE we need to: Create a code verifier: a random string between 43–128 characters in length where the characters consist of letters, digits, underscores, periods, hyphens, or tildes. Create a code challenge: SHA256 hash and base64 encode the code verifier. Open the consent URL and attach the code challenge as a URL query parameter. As an example, we can use Go’s http module to ask for authorization to the user’s playlists: codeVerifier, err := generateRandomString(128) if err != nil { return nil, err } codeChallenge := sha256AndBase64Encode(codeVerifier) apiUrl, err := url.Parse("https://accounts.spotify.com/authorize") if err != nil { return nil, err } query := apiUrl.Query() query.Set("client_id", CLIENT_ID) query.Set("response_type", "code") query.Set("redirect_uri", REDIRECT_URL) query.Set("scope", "playlist-read-private playlist-read-collaborative") query.Set("code_challenge_method", "S256") query.Set("code_challenge", codeChallenge) apiUrl.RawQuery = query.Encode() exec.Command("open", apiUrl.String()).Start() The codeVerifier can be implemented as: func generateRandomString(length int) (string, error) { const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" var result string for i := 0; i < length; i++ { randomInt, err := rand.Int(rand.Reader, big.NewInt(int64(len(possible)))) if err != nil { return "", err } result += string(possible[randomInt.Int64()]) } return result, nil } When hashing and encoding the codeVerifier, make sure to implement base64-URL-encoding where: = is replaced with empty string + is replaced with - / is replaced with _ func sha256AndBase64Encode(input string) string { hash := sha256.Sum256([]byte(input)) replacement := strings.NewReplacer( "=", "", "+", "-", "/", "_", ) return replacement.Replace(base64.URLEncoding.EncodeToString(hash[:])) } After the user accepts the Authorization Request (the Spotify consent URL), we can get an access token using the authorization code and the code verifier. The access token will allow our third-party app access the user’s data on their behalf. When the user accepts the Authorization Request, Spotify will redirect the user to the REDIRECT_URL that we specified (the redirect_uri parameter). For this app, I registered http://localhost:5567/code as the redirect. This means that after Spotify receives our codeChallenge , it will redirect to our redirect URL and include a code query parameter. The code is the authorization code that we need for the access token. To handle the redirect, we can start an http server from our terminal app and write a handler for the /code endpoint. When the Spotify redirects to endpoint, the handler grabs the authorization code (code) and uses it to get an access token. The app saves the access token and uses it to fetch the user data that it wants. Saving the access token allows us to reuse the token on subsequent data requests (presuming we also refresh the token when it expires). This allows the app access to our user’s data without forcing the user into the open-browser-for-authorization loop again. Here’s the overall startup flow for the terminal app: ┌─────┐ │start│ └──┬──┘ ┌──────────▽─────────┐ │show loading spinner│ └──────────┬─────────┘ ______▽______ _______________ ╱ ╲ ╱ ╲ ╱ Has existing ╲_____________________________╱ Is access token ╲___ ╲ access token? ╱yes ╲ is fresh? ╱yes│ ╲_____________╱ ╲_______________╱ │ │no │no │ ┌──────▽──────┐ ┌──────────▽─────────┐ │ │Start PKCE │ │Refresh access token│ │ │authorization│ └──────────┬─────────┘ │ └──────┬──────┘ ┌──────────▽──────────┐ │ ┌──────────▽─────────┐ │Save new access token│ │ │Open browser to │ └──────────┬──────────┘ │ │authorization choice│ │ │ └──────────┬─────────┘ │ │ ┌────────▽───────┐ │ │ │Start local auth│ │ │ │code server │ │ │ └────────┬───────┘ │ │ __________▽__________ ┌─────────────┐ │ │ ╱ ╲ │Get auth code│ │ │ ╱ Did user authorize us ╲______│from redirect│ │ │ ╲ access to their data? ╱yes └──────┬──────┘ │ │ ╲_____________________╱ ┌───────▽───────┐ │ │ │no │Stop local auth│ │ │ ┌──▽─┐ │code server │ │ │ │Quit│ └───────┬───────┘ │ │ └────┘ ┌─────────▽────────┐ │ │ │Fetch access token│ │ │ └─────────┬────────┘ │ │ ┌────────▽────────┐ │ │ │Save access token│ │ │ └────────┬────────┘ │ │ └────────┬───────────┴────────────┘ ┌──────────▽─────────┐ │Show playist spinner│ └──────────┬─────────┘ ┌───────▽──────┐ │Load playlists│ └───────┬──────┘ ┌───────▽──────┐ │Show playlists│ └──────────────┘ Notice how simple the flow is when we already have an existing access token. The local http auth server is implemented as a Go routine and the authorization code is passed between the endpoint handler and the main thread via a channel. Here’s some example code to start-stop the http server and handle the request to /code. type AuthCodeMsg struct { Code string Err error } // Presuming authCodeChannel is created somewhere else func StartLocalAuthCodeServer() chan AuthCodeMsg { if authCodeServer != nil { return authCodeChannel } port := 5567 authCodeServer = &http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: http.DefaultServeMux, } http.HandleFunc("/code", codeEndpointHandler) go func() { if err := authCodeServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Could not listen on :%d: %v\n", port, err) } }() return authCodeChannel } func codeEndpointHandler(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { authCodeChannel <- AuthCodeMsg{Err: err} } code := r.FormValue("code") if code == "" { authCodeChannel <- AuthCodeMsg{Err: fmt.Errorf("code parameter is missing")} } authCodeChannel <- AuthCodeMsg{Code: code, Err: nil} w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "Authorization code received. You may close this browser window.") } func StopLocalAuthCodeServer() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := authCodeServer.Shutdown(ctx); err != nil { log.Fatalf("Server forced to shutdown: %v", err) } } The main thread can wait for the authorization code from the channel with something like codeMsg := <-authCodeChannel. When it receives the code, it can proceed with fetching the access token. This solves both of our authorization problems: Handling user browser input required for OAuth Getting the authorization code after the user consents to having the app access their data Next time, we’ll describe how this authorization flow is implemented within the terminal app while navigating Charm’s event loop.

CI/CD for a chat bot

Originally written in 2019. In a previous post, I created a Chat Bot Auto Responder. That little project was written in Go. It was deployed directly to GCP as a Cloud Function via the gcloud CLI. Writing a chat bot auto responder for GroupMe I used two Cloud Functions, one for testing (responding to a sandbox channel), and one for the actual channel (production). After the initial launch, I pushed the code up to GitHub and wanted to use GitHub Actions as the CI/CD mechanism for future development. This post describes the CI/CD workflow I wanted and what I settled on. The Problem It is very likely that I will be the lone developer on this project for the foreseeable future. I want each new commit to cause tests to run and then deploy the code to the sandbox (DEV) cloud function for further manual testing. When the new code is stable-enough, then I’d like the code to be pushed to the production (PROD) cloud function. What I Wanted I chose GitHub Actions so I didn’t have to integrate with any other third parties¹. GitHub Actions allows us a way to create workflow(s) of actions that react to certain events that occur on a given repository. I wanted the following to occur whenever a commit was pushed to the repo: The GitHub workflow I wanted When a commit is pushed to the repo, then: The unit tests are run (via a golang docker container). If the commit is pushed onto the master branch (i.e. pull-request merged), then deploy to the PROD cloud function via the GCP CLI Action (“Dummy 1” in the image). Else if the commit is pushed to a non-master branch (i.e. a commit on a feature branch), then deploy to the DEV cloud function via the GCP CLI Action (“Dummy 2” in the image). The underlying DSL for the workflow looks like this: # main.workflow workflow "Test & Deploy" { resolves = [ "Test", "Master Only", "Non-Master Only", "Dummy 1", "Dummy 2" ] on = "push" } action "Master Only" { uses = "actions/bin/filter@3c98a2679187369a2116d4f311568596d3725740" args = "branch master" needs = ["Test"] } action "Test" { uses = "actions/docker/cli@8cdf801b322af5f369e00d85e9cf3a7122f49108" args = "build ." } action "Dummy 1" { uses = "actions/docker/cli@8cdf801b322af5f369e00d85e9cf3a7122f49108" args = "build ." needs = ["Master Only"] } action "Non-Master Only" { uses = "actions/bin/filter@3c98a2679187369a2116d4f311568596d3725740" args = "not branch master" needs = ["Test"] } action "Dummy 2" { uses = "actions/docker/cli@8cdf801b322af5f369e00d85e9cf3a7122f49108" args = "build ." needs = ["Non-Master Only"] } The problem is that workflows require every action to be successful. It does not support either/or branching. This means that the “Non-Master Only” and “Master Only” filter actions have to succeed. If either of them fail, then all dependent actions are cancelled (the deploy dummy actions are cancelled). What I Settled On I wanted to use one workflow to cover both master and non-master branches so that I could reuse as many actions as possible. I also wanted less noise in the Actions tab on GitHub; I didn’t want a “master” workflow to run when, most of time, commits would be pushed onto non-master branches. In order to get my desired CI/CD flow, I needed to create two workflows (that are required to live in the same main.workflow file). The GitHub workflow I ended up with The image shows the workflow for the master branch. When a push is made onto master, the non-master workflow looks like: The workflow for a non-master branch push Both workflows run on every single commit. The workflow DSL then becomes: # main.workflow workflow "Master: Test & Deploy" { resolves = [ "Deploy PROD", ] on = "push" } workflow "Branches: Test & Deploy" { resolves = [ "Deploy DEV", ] on = "push" } action "Master Only" { uses = "actions/bin/filter@3c98a2679187369a2116d4f311568596d3725740" args = "branch master" } action "Non-Master Only" { uses = "actions/bin/filter@3c98a2679187369a2116d4f311568596d3725740" args = "not branch master" } action "Test" { uses = "actions/docker/cli@8cdf801b322af5f369e00d85e9cf3a7122f49108" args = "build ." } action "Auth with GCloud" { uses = "actions/gcloud/auth@ba93088eb19c4a04638102a838312bb32de0b052" secrets = ["GCLOUD_AUTH"] } action "Deploy DEV" { uses = "actions/gcloud/cli@ba93088eb19c4a04638102a838312bb32de0b052" needs = ["Test", "Non-Master Only", "Auth with GCloud"] args = "functions deploy <CLOUD_FUNC_NAME> --project <PROJECT_NAME> --runtime go111 --trigger-http" } action "Deploy PROD" { uses = "actions/gcloud/cli@ba93088eb19c4a04638102a838312bb32de0b052" needs = ["Test", "Master Only", "Auth with GCloud"] args = "functions deploy <CLOUD_FUNC_NAME> --entry-point <FUNC_ENTRY> --project <PROJECT_NAME> --runtime go111 --trigger-http" } Note that the GCP secret key (GCLOUD_AUTH) is stored directly on GitHub (not in source control) and belong to a GCP service account that can only manipulate these two cloud functions. I was able to reuse the GCP authentication and test actions, though visualizing the two workflows by reading just the DSL is a bit difficult. This workflow runs tests, authenticates to GCP, and filters the git branch all in parallel. When either of these three steps fail, all the other steps are cancelled or fail also. Overall, not a bad setup. Deploying to DEV on every commit could trample over the work of others, but it’s okay since I’m the only developer 😅. ¹: Granted, I do like CircleCI’s product offering.

Writing a chat bot auto responder for GroupMe

Originally written in 2019. Chat bots are popular in the industry right now. They are used for customer service, devops, and even product management. In this post, I’ll dive into writing a very simple bot while dealing with an inconsistent chat service API. The Problem An organization that I belong to uses GroupMe as their group chat solution. When new members join the group chat (channel), then someone from the leadership team sends them a direct message (DM) welcoming them and asking them to fill out a google form survey. Since we’re not always active in the channel, we run the risk on missing a quick turnaround time from someone joining the channel and us reaching out to them (attrition is a problem). I felt that this process could use some automation. The Constraints I wanted a lightweight solution (i.e. don’t change the process too much). The solution, if it involved tech, should be cheap (a.k.a. cost $0). The channel user activity was relatively low (mostly used for announcements and some bursts of chatter). The solution should still feel “high-touch”. It should feel personal when user contact is made. Solution: Make an Auto Responder When new members join the channel, have something automatically DM that person, greeting them and asking them to fill out our survey. The question then becomes, how? GroupMe has a notion of chat bots, server-side configured entities that join and listen to all the messages and actions that happen in a given channel. For each event that happens, it sends a callback (via HTTP) for you to reason about. A possible auto responder could work like this: Sequence diagram showing how the an autoresponder could interact with GroupMe Straight-forward. How do we deal with the constraints? Lightweight: The process stays the same; user joins, we send them a message. Cheap: We own the auto responder service, so we should host it somewhere where costs are free (GCP / AWS / Heroku micro tiers are all viable). Scale: The cheapest cloud hosting tiers are sufficient from a throughput and minimal response time standpoint. High-Touch: If we can send them a message as one of us, instead of the bot, even better. The first-launched version of this setup is written in Go and runs as a CloudFunction in GCP¹. The CloudFunction was estimated to be free given our traffic rates. The choice to use Go was because there are only a few languages that CloudFunctions support: javascript (via node), python, and go. I find no joy in coding in javascript. I hadn’t written a lick of python in many years. I didn’t know Go (still don’t), but thought it could be fun to learn a bit of it for a small side project. Issues The GroupMe bot sends a callback request for every bit of activity in the channel that it’s listening to. The callback payload from the GroupMe bot looks like the following: { "attachments": [], "avatar_url": "https://i.groupme.com/123456789", "created_at": 1302623328, "group_id": "1234567890", "id": "1234567890", "name": "GroupMe", "sender_id": "2347890234", "sender_type": "system", "source_guid": "sdldsfv78978cvE23df", "system": true, "text": "Alice added Bob to the group.", "user_id": "1234567890" } I need enough information from this notification to: deduce whether this is a “user joined the group” event if so, get a unique user identifier so that I can message the user directly There wasn’t an “event type” for the payload, so I used regular expressions on the text attribute to infer whether a payload corresponded to the two possible join events (a user joined the group on their own and a set of users were invited to the group an existing group member). I thought that the user_id was the id of the user that joined the group. I was wrong. In the wild, the user_id is the id of the user that created the text. So if a user sends a message to the channel, the id belongs to that user. For “join events” the user that wrote that “message” to the channel is the system (GroupMe) which has the special id of 0. There’s no point in sending a direct message to the system. Without a user id, I could not send a message to that user through the GroupMe /direct_messages API. I needed to get the user id(s) another way. One option was to look up the group’s member list from the /groups/:id API. I would have to match up the user’s name against the list of members (though names are also mutable). That API also doesn’t support any member list filtering, sorting, or pagination. I didn’t want to use an API where its response body would grow at the rate of users being added to the group. A second option would be to not rely on the GroupMe bot events at all. There exists a long-polled or websockets API for GroupMe. I could have listened to our channel on my own and reacted to its push messages. The problem with this approach is that the payload looks basically like the bot’s payload. [ { "id": "5", "clientId": "0w1hcbv0yv3puw0bptd6c0fq2i1c", "channel": "/meta/connect", "successful": true, "advice": { "reconnect": "retry", "interval": 0, "timeout": 30000 } }, { "channel": "/user/185", "data": { "type": "line.create", "subject": { "name": "Andygv", "avatar_url": null, "location": { "name": null, "lng": null, "foursquare_checkin": false, "foursquare_venue_id": null, "lat": null }, "created_at": 1322557919, "picture_url": null, "system": false, "text": "hey", "group_id": "1835", "id": "15717", "user_id": "162", "source_guid": "GUID 13225579210290" }, "alert": "Andygv: hey" }, "clientId": "1lhg38m0sk6b63080mpc71r9d7q1", "id": "4uso9uuv78tg4l7csica1kc4c", "authenticated": true } ] Also I didn’t want to have my app be long-lived (hosting costs), since join events aren’t as common as other channel activity. Note that there isn’t an API to get an individual user’s information (aside from your own). I chose a third option. When a “join event” is sent from the bot, I would ask for the most recent N messages from that channel, match up the join event message id with the message id for that event in the channel (they’re the same!), and we the message data to get the user id. Take a look at a responses from the :group_id/messages API: { "response": { "count": 42, "messages": [ { "attachments": [], "avatar_url": null, "created_at": 1554426108, "favorited_by": [], "group_id": "231412342314", "id": "155442610860071985", "name": "GroupMe", "sender_id": "system", "sender_type": "system", "source_guid": "5053cc60396c013725b922000b9ea952", "system": true, "text": "Bob added Alice to the group.", "user_id": "system", "event": { "type": "membership.announce.added", "data": { "added_users": [{ "id": 1231241235, "nickname": "Alice" }], "adder_user": { "id": 234234234, "nickname": "Bob" } } }, "platform": "gm" } ], "meta": { "code": 200 } } } Surprisingly, each message has an optional event attribute with a type and applicable user ids! I wish the event was included in the callback from the bot. The updated sequence flow looks like: Updated sequence diagram showing how the auto responder actually works with GroupMe Extras The GroupMe API requires a token for authentication. This token is stored as an environment variable on the CloudFunction and is not stored in version control. Basic stuff. There is a single http client used across invocations of the cloud function. This allows me to use connection pooling so that I can avoid multiple SSL handshakes when talking to the GroupMe API. Intentional Holes This setup works as intended, but there are cases that I purposefully don’t account for. It may be possible for GroupMe to send duplicate events and the responder does not care. It does not store data on whether it has responded to the same event. I haven’t seen duplicate events yet, but even if they occurred, I deemed “users receiving dupe messages” as OK (low traffic channel). It is also possible that GroupMe’s bot API may not send events at all. There is no reconciliation process to check that every join-event has been handled. ¹: I originally wrote all of this in Elixir/Phoenix and ran it in GCP AppEngine. The problem was that in order to run Elixir code, I needed to run on AppEngine’s Flex Environment, which is not a free tier. Sad, because Elixir is my current favorite language.

Generating custom resumes for job applications using Terminal UIs

I’m in the middle of job search. Like many in the technology industry, I was laid off in late 2023. I thought getting some extended time away from a job would be a nice change of pace. Refreshing even. Not the case. I had this nagging anxiety or worry about what my next role would be: Do I continue doing more of the same (honing existing skills is a good thing)? Do I try to jump up a level with a broader set of responsibilities? Do I switch careers again since I’ve jumped between individual contributor and manager roles in the past? Fortunately, I have some savings and an awesome and employed wife (thank you, Jesus) so I have space to think through these meta questions. Maybe I’ll write about how I approached them someday. Writing resumes for each job application Right now, I have a grand total of 94 job applications to companies in a variety of fields that drew my attention. I applied to roles as an software engineer, a manager/director, and to executive roles (at smaller startups). This means that the accomplishments in my submitted resumes have also been tailored for each application. I presume that it’s wiser to highlight engineering accomplishments when applying for an engineer role. And vice versa for management roles. I don’t want my resume to be too long either; recruiters spend mere seconds reviewing a single resume. With tips from friends and easy-to-customize designs from Canva, I created separate resumes for each role. And each resume fit into one page. That’s not a bad result when trying to simplify a nearly two decade career in tech! While Canva mostly worked, it is also a WYSIWYG app. That means that if I wanted to reposition, add, or remove content, I needed to then adjust the alignment and spacing for the surrounding content and export the result as a PDF. It’s a straight-forward but also tedious process. How my previous resume was maintained 🛠 My previous resume before using Canva was written in Markdown (MD) and CSS. I would write the content in MD and style the layout with CSS. I used Pandoc to transform the MD+CSS into a PDF using weasyprint as the underlying PDF engine. pandoc resume.md --pdf-engine=weasyprint -s --section-divs \ -c style.css -o resume.pdf This setup was also OK. Editing text files is better for my workflow. The CSS adjusted for spacing issues automatically as the content changed. And CSS properties like page-break-inside made for easy reading (i.e. keeping all accomplishments for a given job on the same page). Except I’m not a great designer so the overall design wasn’t as “clean”. And as I would find out later: PDF engines don’t understand many CSS properties. And I would have had to keep separate MD files if I wanted to subset my resume into role buckets (engineer-focused, manager-focused). Tinkering and Ideas 🤔 I’ve been interested in the Terminal User Interfaces (TUI) lately. A number of them have been featured in online forums that I visit. They reminded a lot of old bulletin board games (quite nostalgic). So, I had the idea of making a resume generator using a TUI as the interface. Writing a resume generator The resume generator needed to combine the things I like from having a resume in Canva and in Markdown. Given my professional employment history and full list of accomplishments per job: Allow me to easily pick which accomplishments to include in the generated resume via the command-line. Support CSS for styling of the generated resume. Support PDF as the final output format of the generated resume. Keep the generated resume to one printed page¹. Allow me to easily edit logistic info in the resume (skills, contact info, education, etc). I decided to try out Charm as the TUI framework. Specifically, their huh library seemed like a good starting point for how I can do the “accomplishment picking”. The first end-to-end iteration of the generator worked in the following way: Display the accomplishment multi-select form (the accomplishments were hard-coded into the app). I would select accomplishments I wanted. Perform variable substitution of the accomplishments into a HTML file. This was done with Go Templates since the app was written in Golang. Charm is a library for Go, so choosing Go as the base language was a choice made for me since I decided on Charm in the first place. Use Pandoc to transform the resulting HTML file into a PDF. Fun with PDF engines, or not… I recreated my favorite Canva resume template in HTML + CSS. I used Tailwind to help me style it. But the resulting PDF look at all like the HTML. Even when I pre-processed a static CSS file to include the Tailwind properties I used in the HTML (via tailwindcss), Pandoc and the PDF engine just didn’t properly interpret the CSS properties. I could have spent more time trying to make Pandoc happy: like rewriting the CSS without Tailwind. That would have been more prudent. But I found having Tailwind available made making layout adjustments easier. So I considered the alternative of dropping Pandoc. What is really good at rendering HTML/CSS and exporting to PDF? Web browsers. I got the idea to use a headless Chromium instance to render the resume HTML and then have it export the page to PDF. I used a Playwright library for Go to do this. Aside from being a more heavyweight process (launching a browser), it worked really well. Open-sourcing After showing this to my wife, she asked if she could do this with her resume too. That began the journey of “refactoring so that someone else can generate custom resumes for themselves”. You can find the code at GitHub. A screenshot of selecting accomplishments in the resume generator The current workflow is now: Read resume data from data.json. Display the accomplishment multi-select form with Charm/huh. Select accomplishments I wanted via keyboard. Perform variable substitution of the accomplishments into a HTML file via Go Templates. Export the template into HTML in /tmp. Launch Playwright and have it open the HTML file in /tmp. Ask Playwright to export a PDF of the HTML page into the current directory. I can highlight relevant accomplishments in my resume on per job-application basis by generating a resume specific for the job application. Screenshot of example resume PDF I considered using some form of GenAI to take in the job description, my full list of accomplishments and write me a resume. But I didn’t want to work with the AI to adjust its writing style to mine nor did I want to figure out how to extract its output into my HTML layout². Maybe I’ll have the energy to play with this later, but now isn’t the time. If you’re job hunting right now and are feeling overwhelmed, then I know the feeling. You’re not alone 💛. ¹: I didn’t end up implementing the keep-on-one-page feature. Didn’t need it in the end. ²: I would also want to do all of this locally and not send ChatGPT (or friends) too much of my information.