API-Controlled Alfred Workflows: An Experiment
Can we write a bare-bones Alfred workflow and have a remote API make all of the decisions? Maybe.
I am currently building out a complicated, multi-step Alfred workflow. I built the whole thing conventionally using workflow objects and scripts (using the excellent AwGo), then wired it all up to my API. In the end, it looked... complicated. When I finished my first thought was: "This doesn't feel maintainable. I'm going to be afraid to touch this if I leave it for too long."
Then this idea popped into my head, a scratch that needed itching: The logic is already all in my API. Can I just power this whole workflow right from there? I would love to just return JSON that Alfred understands right from the API and have the workflow do the least amount of interpretation possible, just serve as an input collector and let the API make all of the decisions.
Some Context
To give context: The workflow is for adding IP whitelist rules in Blip. If you're building the rule from scratch and not using a preset, the bare minimum number of steps are:
Kick off the workflow with
addmyip
(adds your current IP to a firewall)Select your provider
Select your resource
Select a port and protocol
Select an expiration time
If you'd like to add a custom IP address, you kick off the workflow with an input that allows you to enter it. If you select a provider that needs a region before we can list its resources (such as AWS), we need to show you that list first. If you choose a custom expiration time (not from the pre-defined list), we present you with an input to enter that. You get the idea. There are a fair number of conditions and routing happening on the Alfred side.
The Concept
Here's the ideal scenario: the Alfred workflow should just be a small, recursive workflow that simply tells that API what it has so far and asks if there's anything else it needs. It routes to the correct type of input based on the API response, collects the input, then repeats the process. Something like this:
This would rely heavily on Alfred's workflow variables to build and maintain the payload as we went along. Variables would also be responsible for signaling the type of input we need and whether or not we were done collecting information (stopping the recursion).
How do we handle the recursion? External triggers! Define a trigger at the start of the workflow and call it at the end to start all over again.
Let's give this thing a whirl.
Proof of Concept
Should be noted: None of what follows is production-ready code. This is merely a proof of concept.
The Alfred Scripts
Ok! Let's get into it. The Go side of this is very simple, that's the point. The Run Script object from the diagram above simply runs this command:
1./alfred-recursive fetch
The Script Filter object runs this command:
1./alfred-recursive scriptFilter
Cool. Let's check out the Go script:
1package main 2 3import ( 4 "bytes" 5 "encoding/json" 6 "io" 7 "net/http" 8 "os" 9 10 aw "github.com/deanishe/awgo"11)12 13var (14 wf *aw.Workflow15)16 17func init() {18 wf = aw.New()19}20 21func run() {22 command := wf.Args()[0]23 24 if command == "scriptFilter" {25 // If we're in the Script Filter, the API has already given us the items26 // it wants to return as a JSON encoded value, just send it back to Alfred27 os.Stdout.Write([]byte(wf.Config.Get("items")))28 return29 }30 31 fetchFromApi()32}33 34func fetchFromApi() {35 // For local testing, allow us to override the API36 // url with a non-exported config variable37 apiUrl := wf.Config.Get("apiUrl", "https://ipblip.com")38 39 jsonMap := map[string]string{40 "params": wf.Config.Get("params"),41 "fieldName": wf.Config.Get("fieldName"),42 "fieldValue": wf.Config.Get("fieldValue"),43 }44 45 jsonData, err := json.Marshal(jsonMap)46 47 if err != nil {48 wf.FatalError(err)49 }50 51 bodyReader := bytes.NewReader(jsonData)52 53 req, err := http.NewRequest(http.MethodPost, apiUrl+"/api/alfred", bodyReader)54 55 if err != nil {56 wf.FatalError(err)57 }58 59 req.Header.Set("Content-Type", "application/json")60 req.Header.Set("Accept", "application/json")61 req.Header.Set("Authorization", "Bearer "+wf.Config.Get("apiKey"))62 req.Header.Set("Blip-Integration", "alfred")63 // Send the workflow and Alfred versions as headers in case64 // we ever need to take it into account on the API side65 req.Header.Set("Alfred-Workflow-Version", wf.Version())66 req.Header.Set("Alfred-Version", wf.Config.Get(aw.EnvVarAlfredVersion))67 68 res, resError := http.DefaultClient.Do(req)69 70 if resError != nil {71 wf.FatalError(resError)72 }73 74 resBody, readError := io.ReadAll(res.Body)75 76 if readError != nil {77 wf.FatalError(readError)78 }79 80 apiData := map[string]string{}81 82 jsonError := json.Unmarshal([]byte(resBody), &apiData)83 84 if jsonError != nil {85 wf.FatalError(jsonError)86 }87 88 // The API has already provided us with Alfred-ready JSON as the output,89 // just send it right back to Alfred90 os.Stdout.Write([]byte(apiData["output"]))91}92 93func main() {94 wf.Run(run)95}
The API Side
I'm using Laravel to power the API, along with my Alfred PHP Workflow library to generate the Alfred JSON. The basic concept:
When we receive a request, the
params
field will be a JSON-encoded payload of all of the information we've collected so far.We check the payload for the fields we need, in order. Once we find one we don't have, we send back the appropriate JSON to collect that information from the user.
We then add the collected information to the payload for the next request.
Let's take a look at this in action in our controller. The build
method handles the request:
1<?php 2 3namespace App\Http\Controllers; 4 5use Alfred\Workflows\Workflow; 6use App\Models\Provider; 7use Illuminate\Http\Request; 8use Illuminate\Support\Collection; 9 10class AlfredRecursiveController extends Controller11{12 protected Collection $params;13 14 protected Workflow $wf;15 16 public function build(Request $request)17 {18 $this->params = collect(json_decode($request->input('params'), true) ?: []);19 20 $this->wf = new Workflow();21 22 if (!$this->hasParam('provider_id')) {23 $request->user()->providers->each(function (Provider $p) {24 $this->wf25 ->items()26 ->add()27 ->title($p->name)28 ->subtitle($p->label)29 ->arg($p->hashid)30 ->icon("images/{$p->provider->value}.png")31 // If they select this item, set the new "params" Alfred variable32 // to include this value as well33 ->variable(34 'params',35 $this->params36 ->pipe(function ($a) use ($p) {37 $a->offsetSet('provider_id', $p->hashid);38 39 return $a;40 })41 ->toJson()42 );43 });44 45 return [46 'output' => $this->wf->setFromRunScript()->variables([47 'items' => $this->wf->output(false),48 ])->output(false),49 ];50 }51 }52 53 protected function hasParam($key)54 {55 return $this->params->offsetExists($key);56 }57}
Initial Result
Ok great! What does this get us so far? Let's see:
The Run Script object runs
fetch
and gets the response from the API, outputs theoutput
from above directly to AlfredNext up is the conditional:
We haven't even set the done
or type
variable, so we default to the Script Filter
The Script Filter runs the
scriptFilter
command of our Go script, which simply echos out theitems
returned by the API:
When the user selects an item, it overwrites the params
variable (as noted above in the PHP script) and we head back to the beginning!
It should be noted that in order to get searching to work, you have to check off "Alfred filters results" in the Script Filter object.
Collecting a Custom Input
So far, so good. We are now able to select items from an API-provided list and save them. But what if we need custom input from the user? Let's lean on workflow variables again.
Let's add a check for the ip_address
field below our provider_id
check:
1<?php 2 3namespace App\Http\Controllers; 4 5use Alfred\Workflows\Workflow; 6use App\Models\Provider; 7use Illuminate\Http\Request; 8use Illuminate\Support\Collection; 9 10class AlfredRecursiveController extends Controller11{12 protected Collection $params;13 14 protected Workflow $wf;15 16 public function build(Request $request)17 {
18 $this->params = collect(json_decode($request->input('params'), true) ?: []);19 20 $this->wf = new Workflow();21 22 if (!$this->hasParam('provider_id')) {23 $request->user()->providers->each(function (Provider $p) {24 $this->wf25 ->items()26 ->add()27 ->title($p->name)28 ->subtitle($p->label)29 ->arg($p->hashid)30 ->icon("images/{$p->provider->value}.png")31 // If they select this item, set the new "params" Alfred variable32 // to include this value as well33 ->variable(34 'params',35 $this->params36 ->pipe(function ($a) use ($p) {37 $a->offsetSet('provider_id', $p->hashid);38 39 return $a;40 })41 ->toJson()42 );43 });44 45 return [46 'output' => $this->wf->setFromRunScript()->variables([47 'items' => $this->wf->output(false),48 ])->output(false),49 ];50 }51 52 if (!$this->hasParam('ip_address')) { 53 return [54 'output' => $this->setVariables([55 'type' => 'userInput',56 'inputTitle' => 'Enter the IP address you\'d like to authorize',57 'inputSubtitle' => 'e.g. 130.196.136.185',58 'fieldName' => 'ip_address',59 ])->output(false),60 ];61 }62 }63 64 protected function hasParam($key)65 {66 return $this->params->offsetExists($key);67 }68}
setVariables
is a helper method that by default nulls
out any potentially re-usable variables in our workflow so that we start fresh on each recursion and overwrites them if necessary. It looks like this:
1<?php 2 3namespace App\Http\Controllers; 4 5use Alfred\Workflows\Workflow; 6use App\Models\Provider; 7use Illuminate\Http\Request; 8use Illuminate\Support\Collection; 9 10class AlfredRecursiveController extends Controller11{12 protected Collection $params;13 14 protected Workflow $wf;15 16 public function build(Request $request)17 {
18 $this->params = collect(json_decode($request->input('params'), true) ?: []);19 20 $this->wf = new Workflow();21 22 if (!$this->hasParam('provider_id')) {23 $request->user()->providers->each(function (Provider $p) {24 $this->wf25 ->items()26 ->add()27 ->title($p->name)28 ->subtitle($p->label)29 ->arg($p->hashid)30 ->icon("images/{$p->provider->value}.png")31 // If they select this item, set the new "params" Alfred variable32 // to include this value as well33 ->variable(34 'params',35 $this->params36 ->pipe(function ($a) use ($p) {37 $a->offsetSet('provider_id', $p->hashid);38 39 return $a;40 })41 ->toJson()42 );43 });44 45 return [46 'output' => $this->wf->setFromRunScript()->variables([47 'items' => $this->wf->output(false),48 ])->output(false),49 ];50 }51 52 if (!$this->hasParam('ip_address')) {53 return [54 'output' => $this->setVariables([55 'type' => 'userInput',56 'inputTitle' => 'Enter the IP address you\'d like to authorize',57 'inputSubtitle' => 'e.g. 130.196.136.185',58 'fieldName' => 'ip_address',59 ])->output(false),60 ];61 } 62 }63 64 protected function hasParam($key)65 {
66 return $this->params->offsetExists($key); 67 }68 69 protected function setVariables(array $variables) 70 {71 return $this->wf->setFromRunScript()->variables(array_merge([72 'type' => null,73 'fieldValue' => null,74 'fieldName' => null,75 'params' => $this->params->toJson(),76 ], $variables));77 }78}
This way we don't get any stale variables on the next go-around and we can be confident our workflow conditionals are picking up the right values.
So how does user input work? Let's zoom in on this part of our workflow:
So: we collect the user input, set it to the fieldValue
variable, and send it back up to the API. As a reminder, these are the fields we are sending to the API with every request from the Go script:
1package main
2 3import ( 4 "bytes" 5 "encoding/json" 6 "io" 7 "net/http" 8 "os" 9 10 aw "github.com/deanishe/awgo"11)12 13var (14 wf *aw.Workflow15)16 17func init() {18 wf = aw.New()19}20 21func run() {22 command := wf.Args()[0]23 24 if command == "scriptFilter" {25 // If we're in the Script Filter, the API has already given us the items26 // it wants to return as a JSON encoded value, just send it back to Alfred27 os.Stdout.Write([]byte(wf.Config.Get("items")))28 return29 }30 31 fetchFromApi()32} 33 34func fetchFromApi() {35 // For local testing, allow us to override the API36 // url with a non-exported config variable37 apiUrl := wf.Config.Get("apiUrl", "https://ipblip.com")38 39 jsonMap := map[string]string{40 "params": wf.Config.Get("params"),41 "fieldName": wf.Config.Get("fieldName"),42 "fieldValue": wf.Config.Get("fieldValue"),43 }
44 45 jsonData, err := json.Marshal(jsonMap)46 47 if err != nil {48 wf.FatalError(err)49 }50 51 bodyReader := bytes.NewReader(jsonData)52 53 req, err := http.NewRequest(http.MethodPost, apiUrl+"/api/alfred", bodyReader)54 55 if err != nil {56 wf.FatalError(err)57 }58 59 req.Header.Set("Content-Type", "application/json")60 req.Header.Set("Accept", "application/json")61 req.Header.Set("Authorization", "Bearer "+wf.Config.Get("apiKey"))62 req.Header.Set("Blip-Integration", "alfred")63 // Send the workflow and Alfred versions as headers in case64 // we ever need to take it into account on the API side65 req.Header.Set("Alfred-Workflow-Version", wf.Version())66 req.Header.Set("Alfred-Version", wf.Config.Get(aw.EnvVarAlfredVersion))67 68 res, resError := http.DefaultClient.Do(req)69 70 if resError != nil {71 wf.FatalError(resError)72 }73 74 resBody, readError := io.ReadAll(res.Body)75 76 if readError != nil {77 wf.FatalError(readError)78 }79 80 apiData := map[string]string{}81 82 jsonError := json.Unmarshal([]byte(resBody), &apiData)83 84 if jsonError != nil {85 wf.FatalError(jsonError)86 }87 88 // The API has already provided us with Alfred-ready JSON as the output,89 // just send it right back to Alfred90 os.Stdout.Write([]byte(apiData["output"])) 91}92 93func main() {94 wf.Run(run)95}
Ok great, we're really on our way here. There's one more thing we have to do on the API side: capture the fieldValue
and add it to the params
if we receive it. Let's add that now. First, a helper method:
1<?php 2 3namespace App\Http\Controllers; 4 5use Alfred\Workflows\Workflow; 6use App\Models\Provider; 7use Illuminate\Http\Request; 8use Illuminate\Support\Collection; 9 10class AlfredRecursiveController extends Controller11{12 protected Collection $params;13 14 protected Workflow $wf;15 16 public function build(Request $request)17 {
18 $this->params = collect(json_decode($request->input('params'), true) ?: []);19 20 $this->wf = new Workflow();21 22 if (!$this->hasParam('provider_id')) {23 $request->user()->providers->each(function (Provider $p) {24 $this->wf25 ->items()26 ->add()27 ->title($p->name)28 ->subtitle($p->label)29 ->arg($p->hashid)30 ->icon("images/{$p->provider->value}.png")31 // If they select this item, set the new "params" Alfred variable32 // to include this value as well33 ->variable(34 'params',35 $this->params36 ->pipe(function ($a) use ($p) {37 $a->offsetSet('provider_id', $p->hashid);38 39 return $a;40 })41 ->toJson()42 );43 });44 45 return [46 'output' => $this->wf->setFromRunScript()->variables([47 'items' => $this->wf->output(false),48 ])->output(false),49 ];50 }51 52 if (!$this->hasParam('ip_address')) {53 return [54 'output' => $this->setVariables([55 'type' => 'userInput',56 'inputTitle' => 'Enter the IP address you\'d like to authorize',57 'inputSubtitle' => 'e.g. 130.196.136.185',58 'fieldName' => 'ip_address',59 ])->output(false),60 ];61 } 62 }63 64 protected function hasParam($key)65 {
66 return $this->params->offsetExists($key); 67 }68 69 protected function setVariables(array $variables)
70 {71 return $this->wf->setFromRunScript()->variables(array_merge([72 'type' => null,73 'fieldValue' => null,74 'fieldName' => null,75 'params' => $this->params->toJson(),76 ], $variables)); 77 }78 79 protected function setParams(array $fields) 80 {81 foreach ($fields as $field) {82 if (request()->input('fieldName') === $field && request()->input('fieldValue') !== null) {83 $this->params->offsetSet($field, request()->input('fieldValue'));84 }85 }86 }87}
Let's see how it looks:
Stopping the Recursion
So at some point, we have all of the information we need, and we should tell Alfred to stop looping the workflow. On the API side, after all of our param checks have passed, we simply add this:
1<?php 2 3 4namespace App\Http\Controllers; 5
6use Alfred\Workflows\Workflow; 7use App\Models\Provider; 8use Illuminate\Http\Request; 9use Illuminate\Support\Collection;10 11class AlfredRecursiveController extends Controller12{13 protected Collection $params;14 15 protected Workflow $wf;16 17 public function build(Request $request)18 {
19 $this->params = collect(json_decode($request->input('params'), true) ?: []);20 21 $this->wf = new Workflow();22 23 if (!$this->hasParam('provider_id')) {24 $request->user()->providers->each(function (Provider $p) {25 $this->wf26 ->items()27 ->add()28 ->title($p->name)29 ->subtitle($p->label)30 ->arg($p->hashid)31 ->icon("images/{$p->provider->value}.png")32 // If they select this item, set the new "params" Alfred variable33 // to include this value as well34 ->variable(35 'params',36 $this->params37 ->pipe(function ($a) use ($p) {38 $a->offsetSet('provider_id', $p->hashid);39 40 return $a;41 })42 ->toJson()43 );44 });45 46 return [47 'output' => $this->wf->setFromRunScript()->variables([48 'items' => $this->wf->output(false),49 ])->output(false),50 ];51 }52 53 if (!$this->hasParam('ip_address')) {54 return [55 'output' => $this->setVariables([56 'type' => 'userInput',57 'inputTitle' => 'Enter the IP address you\'d like to authorize',58 'inputSubtitle' => 'e.g. 130.196.136.185',59 'fieldName' => 'ip_address',60 ])->output(false),61 ];62 } 63 64 // This is where you'd save the record or65 // do whatever work needs to be done66 return [ 67 'output' => $this->setVariables([68 'done' => true,69 'notificationTitle' => 'IP Authorized',70 'notificationBody' => $this->params->offsetGet('ip_address') . ' has been authorized in AWS',71 ])->output(false),72 ];73 }74 75 protected function hasParam($key)76 {
77 return $this->params->offsetExists($key); 78 }79 80 protected function setVariables(array $variables)
81 {82 return $this->wf->setFromRunScript()->variables(array_merge([83 'type' => null,84 'fieldValue' => null,85 'fieldName' => null,86 'params' => $this->params->toJson(),87 ], $variables)); 88 }89 90 protected function setParams(array $fields)
91 {92 foreach ($fields as $field) {93 if (request()->input('fieldName') === $field && request()->input('fieldValue') !== null) {94 $this->params->offsetSet($field, request()->input('fieldValue'));95 }96 } 97 }98}
And in our workflow, the Push Notification object looks like this:
Wrapping Up
This was a long one! If you're still with me, thanks for reading.
I think for complex, multi-step Alfred workflows, this is an interesting concept. Let's talk pros and cons:
Pros
Flexible. The workflow itself is just an ultra-flexible executor, so you can adjust logic and/or output from the API side without needing the user to update the workflow itself
Testable. You're already testing your API (right?), just add tests to your
alfred
endpoint and make sure it's receiving the results you're expecting. Infinity times easier than testing the workflow itself.Exception messaging. In my opinion, handling validation and exceptions is so much easier this way than looking for all of the edge cases in the workflow itself and displaying them to the user. If validation fails, you can simply re-serve them the prompt again with a message about the failure and ask for the information.
Cons
Network lag. Caching could be added, but arguably that defeats the purpose of building the workflow this way. But since every single step requires making an API request, they really need to be zippy, and that's not even taking the user's network speed into account. Especially since there is no "loading screen" during the Run Script object, any lag means that the user is staring at a blank screen between prompts. (If anyone has an idea to solve this elegantly I'm all ears).
Too Flexible? I think care should be taken about adjusting the flow of the tool from the API side willy-nilly. Alfred is a productivity tool and users get used to how a certain workflow flows. They want to do something quickly, that's the reason they are using Alfred in the first place. If they see unexpected changes too often they may make a wrong selection or be forced to change their habit, potentially causing frustration.
What do we think? Terrible idea? Genius? Somewhere in between? Let me know! Open to feedback and suggestions.