[{"data":1,"prerenderedAt":6847},["ShallowReactive",2],{"footer-articles":3,"/blog/run-multiple-node-js-apps-on-one-server-with-pm2":3691,"related-/blog/run-multiple-node-js-apps-on-one-server-with-pm2":4402},[4,48,1383,2356],{"id":5,"title":6,"body":7,"category":37,"date":38,"description":13,"draft":39,"extension":40,"image":41,"meta":42,"navigation":43,"path":44,"seo":45,"stem":46,"tighten":43,"__hash__":47},"blog/blog/build-your-own-ai-powered-slack-bot-with-the-laravel-ai-sdk.md","Build Your Own AI-Powered Slack Bot with the Laravel AI SDK",{"type":8,"value":9,"toc":33},"minimark",[10,14,17,28],[11,12,13],"p",{},"You can build your own AI teammate and talk to it via Slack! 🤖",[11,15,16],{},"Sure, you can use a service, but building your own means you own the memory, choose the model, and can tweak it to fit your needs.",[11,18,19,20,27],{},"Learn how in my latest article for ",[21,22,26],"a",{"href":23,"rel":24},"https://tighten.com",[25],"nofollow","Tighten"," (Spoiler alert: Laravel AI SDK 🔥)",[29,30],"article-preview",{"image":31,"link":32,"title":6},"https://tighten.com/assets/images/insights/build-your-own-ai-powered-slack-bot-with-the-laravel-ai-sdk-preview.jpg","https://tighten.com/insights/build-your-own-ai-powered-slack-bot-with-the-laravel-ai-sdk/",{"title":34,"searchDepth":35,"depth":35,"links":36},"",2,[],"AI","2026-08-26",false,"md","/img/blog/build-your-own-ai-powered-slack-bot-with-the-laravel-ai-sdk.jpg",{},true,"/blog/build-your-own-ai-powered-slack-bot-with-the-laravel-ai-sdk",{"title":6,"description":13},"blog/build-your-own-ai-powered-slack-bot-with-the-laravel-ai-sdk","90ke2wqCbYfUfw46PDOgJ0Kny1LHcyo6Xsp4VQYZanU",{"id":49,"title":50,"body":51,"category":37,"date":1375,"description":1376,"draft":39,"extension":40,"image":1377,"meta":1378,"navigation":43,"path":1379,"seo":1380,"stem":1381,"tighten":39,"__hash__":1382},"blog/blog/climail-the-email-client-for-your-agent.md","climail: The Email Client for Your Agent",{"type":8,"value":52,"toc":1359},[53,63,83,86,95,98,103,114,119,125,180,189,193,196,210,232,236,239,254,509,516,531,535,549,552,556,559,573,576,813,817,820,1062,1072,1088,1092,1098,1186,1192,1207,1210,1232,1242,1263,1267,1270,1273,1276,1285,1292,1295,1301,1317,1321,1324,1342,1346,1352,1355],[11,54,55],{},[56,57,58,62],"strong",{},[59,60,61],"code",{},"climail"," is a clanker-friendly command line email client.",[64,65,66,70,77,80],"ul",{},[67,68,69],"li",{},"📬 Read, write & organize emails",[67,71,72,73,76],{},"⚡ Run instantly with ",[59,74,75],{},"npx"," (no install)",[67,78,79],{},"🔐 IMAP + SMTP",[67,81,82],{},"💌 Gmail ready",[11,84,85],{},"Built for agents 🤖 (and humans, too!)",[11,87,88],{},[89,90],"video",{"src":91,"poster":92,"controls":43,"playsInline":43,"preload":93,"style":94},"https://github.com/user-attachments/assets/192c5f2f-4456-49e3-9851-2b9b5c797a44","/img/blog/give-your-agent-an-inbox-with-climail.png","metadata","width:100%;border:0;border-radius:16px;margin:1.5rem 0;display:block",[11,96,97],{},"It's a minimalist email client that lives in your terminal. Every command hands back clean JSON, so an AI agent can read your inbox as easily as you can.",[99,100,102],"h2",{"id":101},"features","Features",[11,104,105,106,108,109,113],{},"There isn't much to ",[59,107,61],{},", and that's on purpose. It does a handful of things, and it tries to do each of them in the ",[110,111,112],"em",{},"least"," surprising way possible.",[115,116,118],"h3",{"id":117},"_1-just-npx","1) Just npx",[11,120,121,122,124],{},"There's nothing to install and no process to keep alive. It's just an ",[59,123,75],{}," command that uses IMAP and SMTP under the hood.",[126,127,131],"pre",{"className":128,"code":129,"language":130,"meta":34,"style":34},"language-bash shiki shiki-themes monokai","npx climail list --unread\nnpx climail mark 167 read\nnpx climail archive 167\n","bash",[59,132,133,152,167],{"__ignoreMap":34},[134,135,138,141,145,148],"span",{"class":136,"line":137},"line",1,[134,139,75],{"class":140},"sHkqI",[134,142,144],{"class":143},"s_Ekj"," climail",[134,146,147],{"class":143}," list",[134,149,151],{"class":150},"s7s5_"," --unread\n",[134,153,154,156,158,161,164],{"class":136,"line":35},[134,155,75],{"class":140},[134,157,144],{"class":143},[134,159,160],{"class":143}," mark",[134,162,163],{"class":150}," 167",[134,165,166],{"class":143}," read\n",[134,168,170,172,174,177],{"class":136,"line":169},3,[134,171,75],{"class":140},[134,173,144],{"class":143},[134,175,176],{"class":143}," archive",[134,178,179],{"class":150}," 167\n",[11,181,182,183,185,186,188],{},"If you can run ",[59,184,75],{},", you can run ",[59,187,61],{},": on your laptop, on a server, everywhere.",[115,190,192],{"id":191},"_2-configure-it-in-a-minute","2) Configure It in a Minute",[11,194,195],{},"Run the setup wizard and answer a few questions:",[126,197,199],{"className":128,"code":198,"language":130,"meta":34,"style":34},"npx climail init\n",[59,200,201],{"__ignoreMap":34},[134,202,203,205,207],{"class":136,"line":137},[134,204,75],{"class":140},[134,206,144],{"class":143},[134,208,209],{"class":143}," init\n",[11,211,212,213,216,217,220,221,223,224,227,228,231],{},"It asks where to save the config: ",[56,214,215],{},"global"," (",[59,218,219],{},"~/.config/climail.conf",", which lets ",[59,222,61],{}," work from any directory, no per-project setup) or ",[56,225,226],{},"local"," (a ",[59,229,230],{},"climail.conf"," in the current directory, handy when a project has its own mailbox). Pick global and you're done. We'll look at the file itself further down.",[115,233,235],{"id":234},"_3-all-commands-return-json","3) All Commands Return JSON",[11,237,238],{},"Every command prints clean JSON to stdout, so there's nothing to scrape and no columns to line up. Ask for your unread mail:",[126,240,242],{"className":128,"code":241,"language":130,"meta":34,"style":34},"npx climail list --unread\n",[59,243,244],{"__ignoreMap":34},[134,245,246,248,250,252],{"class":136,"line":137},[134,247,75],{"class":140},[134,249,144],{"class":143},[134,251,147],{"class":143},[134,253,151],{"class":150},[126,255,259],{"className":256,"code":257,"language":258,"meta":34,"style":34},"language-json shiki shiki-themes monokai","{\n  \"ok\": true,\n  \"mailbox\": \"INBOX\",\n  \"total\": 2481,\n  \"returned\": 2,\n  \"messages\": [\n    {\n      \"seq\": 2480,\n      \"uid\": 166,\n      \"date\": \"2026-07-12T09:03:11.000Z\",\n      \"from\": \"newsletter@somesaas.io\",\n      \"subject\": \"🚀 10 game-changing tips\",\n      \"unread\": true\n    },\n    {\n      \"seq\": 2481,\n      \"uid\": 167,\n      \"date\": \"2026-07-12T09:41:55.000Z\",\n      \"from\": \"sarah@acme.co\",\n      \"subject\": \"Export button throws a 500\",\n      \"unread\": true\n    }\n  ]\n}\n","json",[59,260,261,267,282,295,308,321,330,336,349,362,375,388,401,412,418,423,434,446,458,470,482,491,497,503],{"__ignoreMap":34},[134,262,263],{"class":136,"line":137},[134,264,266],{"class":265},"sCdxs","{\n",[134,268,269,273,276,279],{"class":136,"line":35},[134,270,272],{"class":271},"sOx1s","  \"ok\"",[134,274,275],{"class":265},": ",[134,277,278],{"class":150},"true",[134,280,281],{"class":265},",\n",[134,283,284,287,289,293],{"class":136,"line":169},[134,285,286],{"class":271},"  \"mailbox\"",[134,288,275],{"class":265},[134,290,292],{"class":291},"susgL","\"INBOX\"",[134,294,281],{"class":265},[134,296,298,301,303,306],{"class":136,"line":297},4,[134,299,300],{"class":271},"  \"total\"",[134,302,275],{"class":265},[134,304,305],{"class":150},"2481",[134,307,281],{"class":265},[134,309,311,314,316,319],{"class":136,"line":310},5,[134,312,313],{"class":271},"  \"returned\"",[134,315,275],{"class":265},[134,317,318],{"class":150},"2",[134,320,281],{"class":265},[134,322,324,327],{"class":136,"line":323},6,[134,325,326],{"class":271},"  \"messages\"",[134,328,329],{"class":265},": [\n",[134,331,333],{"class":136,"line":332},7,[134,334,335],{"class":265},"    {\n",[134,337,339,342,344,347],{"class":136,"line":338},8,[134,340,341],{"class":271},"      \"seq\"",[134,343,275],{"class":265},[134,345,346],{"class":150},"2480",[134,348,281],{"class":265},[134,350,352,355,357,360],{"class":136,"line":351},9,[134,353,354],{"class":271},"      \"uid\"",[134,356,275],{"class":265},[134,358,359],{"class":150},"166",[134,361,281],{"class":265},[134,363,365,368,370,373],{"class":136,"line":364},10,[134,366,367],{"class":271},"      \"date\"",[134,369,275],{"class":265},[134,371,372],{"class":291},"\"2026-07-12T09:03:11.000Z\"",[134,374,281],{"class":265},[134,376,378,381,383,386],{"class":136,"line":377},11,[134,379,380],{"class":271},"      \"from\"",[134,382,275],{"class":265},[134,384,385],{"class":291},"\"newsletter@somesaas.io\"",[134,387,281],{"class":265},[134,389,391,394,396,399],{"class":136,"line":390},12,[134,392,393],{"class":271},"      \"subject\"",[134,395,275],{"class":265},[134,397,398],{"class":291},"\"🚀 10 game-changing tips\"",[134,400,281],{"class":265},[134,402,404,407,409],{"class":136,"line":403},13,[134,405,406],{"class":271},"      \"unread\"",[134,408,275],{"class":265},[134,410,411],{"class":150},"true\n",[134,413,415],{"class":136,"line":414},14,[134,416,417],{"class":265},"    },\n",[134,419,421],{"class":136,"line":420},15,[134,422,335],{"class":265},[134,424,426,428,430,432],{"class":136,"line":425},16,[134,427,341],{"class":271},[134,429,275],{"class":265},[134,431,305],{"class":150},[134,433,281],{"class":265},[134,435,437,439,441,444],{"class":136,"line":436},17,[134,438,354],{"class":271},[134,440,275],{"class":265},[134,442,443],{"class":150},"167",[134,445,281],{"class":265},[134,447,449,451,453,456],{"class":136,"line":448},18,[134,450,367],{"class":271},[134,452,275],{"class":265},[134,454,455],{"class":291},"\"2026-07-12T09:41:55.000Z\"",[134,457,281],{"class":265},[134,459,461,463,465,468],{"class":136,"line":460},19,[134,462,380],{"class":271},[134,464,275],{"class":265},[134,466,467],{"class":291},"\"sarah@acme.co\"",[134,469,281],{"class":265},[134,471,473,475,477,480],{"class":136,"line":472},20,[134,474,393],{"class":271},[134,476,275],{"class":265},[134,478,479],{"class":291},"\"Export button throws a 500\"",[134,481,281],{"class":265},[134,483,485,487,489],{"class":136,"line":484},21,[134,486,406],{"class":271},[134,488,275],{"class":265},[134,490,411],{"class":150},[134,492,494],{"class":136,"line":493},22,[134,495,496],{"class":265},"    }\n",[134,498,500],{"class":136,"line":499},23,[134,501,502],{"class":265},"  ]\n",[134,504,506],{"class":136,"line":505},24,[134,507,508],{"class":265},"}\n",[11,510,511,512,515],{},"Every message carries a ",[59,513,514],{},"uid",", and that little number is the ID you pass to every other command.",[11,517,518,519,522,523,526,527,530],{},"It's the same idea as ",[59,520,521],{},"gh"," or ",[59,524,525],{},"kubectl -o json",": a small tool that pipes cleanly into the next thing, whether that next thing is ",[59,528,529],{},"jq"," or an agent.",[115,532,534],{"id":533},"_4-gmail-ready-out-of-the-box","4) Gmail-Ready, Out of the Box",[11,536,537,538,540,541,544,545,548],{},"Labels, archiving, and stars are the operations you actually perform on a Gmail inbox, so ",[59,539,61],{}," speaks them natively. Labeling applies a real Gmail label, archiving drops the ",[59,542,543],{},"Inbox"," label instead of trashing the message (exactly like the archive button in the web UI), and starring toggles ",[59,546,547],{},"\\Flagged",".",[11,550,551],{},"But if you are not using a Google email, don't worry: any email works, as long as you have the IMAP and SMTP credentials.",[99,553,555],{"id":554},"usage","Usage",[11,557,558],{},"Every interaction follows the same shape:",[126,560,562],{"className":128,"code":561,"language":130,"meta":34,"style":34},"npx climail ⟪command⟫\n",[59,563,564],{"__ignoreMap":34},[134,565,566,568,570],{"class":136,"line":137},[134,567,75],{"class":140},[134,569,144],{"class":143},[134,571,572],{"class":143}," ⟪command⟫\n",[11,574,575],{},"Here's the full set of commands. It looks like a lot, but you'll live in five or six of them and reach for the rest when you need them.",[577,578,579,592],"table",{},[580,581,582],"thead",{},[583,584,585,589],"tr",{},[586,587,588],"th",{},"Command",[586,590,591],{},"What it does",[593,594,595,608,628,665,679,694,704,735,751,765,780,793,803],"tbody",{},[583,596,597,603],{},[598,599,600],"td",{},[59,601,602],{},"init",[598,604,605,606,548],{},"Setup wizard for your IMAP credentials. Writes ",[59,607,219],{},[583,609,610,615],{},[598,611,612],{},[59,613,614],{},"list",[598,616,617,618,621,622,621,625,548],{},"List recent messages as JSON, each with its UID. Flags: ",[59,619,620],{},"--count \u003Cn>",", ",[59,623,624],{},"--unread",[59,626,627],{},"--mailbox \u003Cname>",[583,629,630,635],{},[598,631,632],{},[59,633,634],{},"search",[598,636,637,638,621,641,621,644,621,647,621,650,621,653,621,656,621,659,621,661,621,663,548],{},"Search a mailbox. Flags: ",[59,639,640],{},"--from",[59,642,643],{},"--to",[59,645,646],{},"--subject",[59,648,649],{},"--body",[59,651,652],{},"--text",[59,654,655],{},"--since",[59,657,658],{},"--before",[59,660,624],{},[59,662,620],{},[59,664,627],{},[583,666,667,672],{},[598,668,669],{},[59,670,671],{},"labels",[598,673,674,675,678],{},"List available labels/folders. Flag: ",[59,676,677],{},"--counts"," adds message counts per label.",[583,680,681,686],{},[598,682,683],{},[59,684,685],{},"read \u003Cuid>",[598,687,688,689,621,692,548],{},"Fetch one message: parsed text, HTML, and attachment metadata. Flags: ",[59,690,691],{},"--save-attachments \u003Cdir>",[59,693,627],{},[583,695,696,701],{},[598,697,698],{},[59,699,700],{},"label \u003Cuid> \u003Cname>",[598,702,703],{},"Apply a Gmail label, creating it if needed.",[583,705,706,711],{},[598,707,708],{},[59,709,710],{},"mark \u003Cuid> \u003Caction>",[598,712,713,714,717,718,621,721,717,724,727,728,717,731,734],{},"Set flags: ",[59,715,716],{},"read","/",[59,719,720],{},"unread",[59,722,723],{},"flag",[59,725,726],{},"unflag"," (aliases ",[59,729,730],{},"star",[59,732,733],{},"unstar",").",[583,736,737,742],{},[598,738,739],{},[59,740,741],{},"draft-reply \u003Cuid>",[598,743,744,745,621,747,750],{},"Stage a threaded reply in Drafts. Flags: ",[59,746,649],{},[59,748,749],{},"--all",". Nothing is sent.",[583,752,753,758],{},[598,754,755],{},[59,756,757],{},"send",[598,759,760,761,764],{},"Send over SMTP (needs ",[59,762,763],{},"smtp.host","): a new email, a threaded reply, or an existing draft.",[583,766,767,772],{},[598,768,769],{},[59,770,771],{},"forward \u003Cuid>",[598,773,774,775,777,778,548],{},"Forward a message over SMTP. Flag: ",[59,776,643],{}," (required), ",[59,779,649],{},[583,781,782,787],{},[598,783,784],{},[59,785,786],{},"delete \u003Cuid>",[598,788,789,790,548],{},"Delete a message (Trash on Gmail). Flag: ",[59,791,792],{},"--from \u003Cmailbox>",[583,794,795,800],{},[598,796,797],{},[59,798,799],{},"archive \u003Cuid>",[598,801,802],{},"Archive a message (drops the Inbox label on Gmail).",[583,804,805,810],{},[598,806,807],{},[59,808,809],{},"move \u003Cuid> \u003Cmailbox>",[598,811,812],{},"Move a message to another mailbox.",[115,814,816],{"id":815},"examples","Examples",[11,818,819],{},"Reading the table is one thing, but the commands click faster when you see them lined up. Here's a full triage session, top to bottom:",[126,821,823],{"className":128,"code":822,"language":130,"meta":34,"style":34},"npx climail init\nnpx climail list --unread\nnpx climail search --from boss@work.com --since 2026-06-01 --unread\nnpx climail labels --counts\nnpx climail read 167 --save-attachments ./att\nnpx climail mark 167 read\nnpx climail list --mailbox \"[Gmail]/All Mail\" --unread\nnpx climail mark 167 read --mailbox \"[Gmail]/All Mail\"\nnpx climail label 167 Triaged\nnpx climail draft-reply 167 --all --body \"Thanks, will take a look.\"\nnpx climail send --to them@example.com --reply-to 167 --all --body \"On it.\"\nnpx climail send --to a@b.com --subject \"Report\" --body \"Attached.\" --attach ./report.pdf\nnpx climail forward 167 --to colleague@work.com --body \"FYI\"\nnpx climail archive 167\n",[59,824,825,833,843,866,878,895,907,923,940,954,974,1001,1031,1052],{"__ignoreMap":34},[134,826,827,829,831],{"class":136,"line":137},[134,828,75],{"class":140},[134,830,144],{"class":143},[134,832,209],{"class":143},[134,834,835,837,839,841],{"class":136,"line":35},[134,836,75],{"class":140},[134,838,144],{"class":143},[134,840,147],{"class":143},[134,842,151],{"class":150},[134,844,845,847,849,852,855,858,861,864],{"class":136,"line":169},[134,846,75],{"class":140},[134,848,144],{"class":143},[134,850,851],{"class":143}," search",[134,853,854],{"class":150}," --from",[134,856,857],{"class":143}," boss@work.com",[134,859,860],{"class":150}," --since",[134,862,863],{"class":143}," 2026-06-01",[134,865,151],{"class":150},[134,867,868,870,872,875],{"class":136,"line":297},[134,869,75],{"class":140},[134,871,144],{"class":143},[134,873,874],{"class":143}," labels",[134,876,877],{"class":150}," --counts\n",[134,879,880,882,884,887,889,892],{"class":136,"line":310},[134,881,75],{"class":140},[134,883,144],{"class":143},[134,885,886],{"class":143}," read",[134,888,163],{"class":150},[134,890,891],{"class":150}," --save-attachments",[134,893,894],{"class":143}," ./att\n",[134,896,897,899,901,903,905],{"class":136,"line":323},[134,898,75],{"class":140},[134,900,144],{"class":143},[134,902,160],{"class":143},[134,904,163],{"class":150},[134,906,166],{"class":143},[134,908,909,911,913,915,918,921],{"class":136,"line":332},[134,910,75],{"class":140},[134,912,144],{"class":143},[134,914,147],{"class":143},[134,916,917],{"class":150}," --mailbox",[134,919,920],{"class":143}," \"[Gmail]/All Mail\"",[134,922,151],{"class":150},[134,924,925,927,929,931,933,935,937],{"class":136,"line":338},[134,926,75],{"class":140},[134,928,144],{"class":143},[134,930,160],{"class":143},[134,932,163],{"class":150},[134,934,886],{"class":143},[134,936,917],{"class":150},[134,938,939],{"class":143}," \"[Gmail]/All Mail\"\n",[134,941,942,944,946,949,951],{"class":136,"line":351},[134,943,75],{"class":140},[134,945,144],{"class":143},[134,947,948],{"class":143}," label",[134,950,163],{"class":150},[134,952,953],{"class":143}," Triaged\n",[134,955,956,958,960,963,965,968,971],{"class":136,"line":364},[134,957,75],{"class":140},[134,959,144],{"class":143},[134,961,962],{"class":143}," draft-reply",[134,964,163],{"class":150},[134,966,967],{"class":150}," --all",[134,969,970],{"class":150}," --body",[134,972,973],{"class":143}," \"Thanks, will take a look.\"\n",[134,975,976,978,980,983,986,989,992,994,996,998],{"class":136,"line":377},[134,977,75],{"class":140},[134,979,144],{"class":143},[134,981,982],{"class":143}," send",[134,984,985],{"class":150}," --to",[134,987,988],{"class":143}," them@example.com",[134,990,991],{"class":150}," --reply-to",[134,993,163],{"class":150},[134,995,967],{"class":150},[134,997,970],{"class":150},[134,999,1000],{"class":143}," \"On it.\"\n",[134,1002,1003,1005,1007,1009,1011,1014,1017,1020,1022,1025,1028],{"class":136,"line":390},[134,1004,75],{"class":140},[134,1006,144],{"class":143},[134,1008,982],{"class":143},[134,1010,985],{"class":150},[134,1012,1013],{"class":143}," a@b.com",[134,1015,1016],{"class":150}," --subject",[134,1018,1019],{"class":143}," \"Report\"",[134,1021,970],{"class":150},[134,1023,1024],{"class":143}," \"Attached.\"",[134,1026,1027],{"class":150}," --attach",[134,1029,1030],{"class":143}," ./report.pdf\n",[134,1032,1033,1035,1037,1040,1042,1044,1047,1049],{"class":136,"line":403},[134,1034,75],{"class":140},[134,1036,144],{"class":143},[134,1038,1039],{"class":143}," forward",[134,1041,163],{"class":150},[134,1043,985],{"class":150},[134,1045,1046],{"class":143}," colleague@work.com",[134,1048,970],{"class":150},[134,1050,1051],{"class":143}," \"FYI\"\n",[134,1053,1054,1056,1058,1060],{"class":136,"line":414},[134,1055,75],{"class":140},[134,1057,144],{"class":143},[134,1059,176],{"class":143},[134,1061,179],{"class":150},[11,1063,1064,1065,1068,1069,1071],{},"Notice that ",[59,1066,1067],{},"draft-reply"," and ",[59,1070,757],{}," are two different commands:",[64,1073,1074,1083],{},[67,1075,1076,1078,1079,1082],{},[59,1077,1067],{}," writes a proper threaded reply, correct subject and headers, straight into your ",[56,1080,1081],{},"Drafts"," folder. Nothing leaves your account.",[67,1084,1085,1087],{},[59,1086,757],{}," is the one that actually puts mail on the wire, and it only works once you've configured SMTP. Reading your inbox and emailing people on your behalf are two very different levels of trust, so they're two different commands you opt into separately.",[99,1089,1091],{"id":1090},"configuration","Configuration",[11,1093,1094,1097],{},[59,1095,1096],{},"npx climail init"," writes the config for you, but it's a plain text file, so you can just as easily write it yourself:",[126,1099,1103],{"className":1100,"code":1101,"filename":219,"language":1102,"meta":34,"style":34},"language-dotenv shiki shiki-themes monokai","imap.host=imap.gmail.com\nimap.port=993\nimap.secure=true\nimap.username=you@gmail.com\nimap.password=\"app password here\"\n\n# Optional: only needed for the `send` command\nsmtp.host=smtp.gmail.com\nsmtp.port=465\n","dotenv",[59,1104,1105,1117,1127,1136,1146,1156,1161,1167,1176],{"__ignoreMap":34},[134,1106,1107,1110,1114],{"class":136,"line":137},[134,1108,1109],{"class":265},"imap.host",[134,1111,1113],{"class":1112},"s8I7P","=",[134,1115,1116],{"class":265},"imap.gmail.com\n",[134,1118,1119,1122,1124],{"class":136,"line":35},[134,1120,1121],{"class":265},"imap.port",[134,1123,1113],{"class":1112},[134,1125,1126],{"class":265},"993\n",[134,1128,1129,1132,1134],{"class":136,"line":169},[134,1130,1131],{"class":265},"imap.secure",[134,1133,1113],{"class":1112},[134,1135,411],{"class":265},[134,1137,1138,1141,1143],{"class":136,"line":297},[134,1139,1140],{"class":265},"imap.username",[134,1142,1113],{"class":1112},[134,1144,1145],{"class":265},"you@gmail.com\n",[134,1147,1148,1151,1153],{"class":136,"line":310},[134,1149,1150],{"class":265},"imap.password",[134,1152,1113],{"class":1112},[134,1154,1155],{"class":143},"\"app password here\"\n",[134,1157,1158],{"class":136,"line":323},[134,1159,1160],{"emptyLinePlaceholder":43},"\n",[134,1162,1163],{"class":136,"line":332},[134,1164,1166],{"class":1165},"snpHw","# Optional: only needed for the `send` command\n",[134,1168,1169,1171,1173],{"class":136,"line":338},[134,1170,763],{"class":265},[134,1172,1113],{"class":1112},[134,1174,1175],{"class":265},"smtp.gmail.com\n",[134,1177,1178,1181,1183],{"class":136,"line":351},[134,1179,1180],{"class":265},"smtp.port",[134,1182,1113],{"class":1112},[134,1184,1185],{"class":265},"465\n",[11,1187,1188,1189,1191],{},"The IMAP block is all you need to start reading. The SMTP block is optional, and its absence is exactly why ",[59,1190,757],{}," stays locked until you deliberately add it.",[1193,1194,1197],"callout",{"color":1195,"icon":1196},"#057ccd","fa6-solid:circle-info",[11,1198,1199,1200,1203,1204,1206],{},"Gmail won't let a raw IMAP client sign in with your normal account password. You need an ",[56,1201,1202],{},"app password",", a 16-character token you generate in your Google account settings. Drop that into ",[59,1205,1150],{}," and you're in.",[11,1208,1209],{},"Always keep your config file out of version control, since it holds your credentials.",[11,1211,1212,1214,1215,1218,1219,1221,1222,1224,1225,1228,1229,1231],{},[59,1213,602],{}," lets you choose where this file lives. ",[56,1216,1217],{},"Global"," puts it at ",[59,1220,219],{},", following the XDG convention, so ",[59,1223,61],{}," works from any directory. ",[56,1226,1227],{},"Local"," drops a ",[59,1230,230],{}," in the current directory, which takes precedence over the global one when you run commands from there, perfect for a project with its own inbox.",[11,1233,1234,1235,1238,1239,1241],{},"If you manage more than one email address, you can also point any command at a specific file with ",[59,1236,1237],{},"--config"," (which skips the prompt in ",[59,1240,602],{}," too):",[126,1243,1245],{"className":128,"code":1244,"language":130,"meta":34,"style":34},"npx climail list --config ~/work/mail.conf --unread\n",[59,1246,1247],{"__ignoreMap":34},[134,1248,1249,1251,1253,1255,1258,1261],{"class":136,"line":137},[134,1250,75],{"class":140},[134,1252,144],{"class":143},[134,1254,147],{"class":143},[134,1256,1257],{"class":150}," --config",[134,1259,1260],{"class":143}," ~/work/mail.conf",[134,1262,151],{"class":150},[99,1264,1266],{"id":1265},"an-email-client-for-agents","An Email Client for Agents",[11,1268,1269],{},"So that's the tool. I created it to give my agent an easy way of interacting with an inbox.",[11,1271,1272],{},"These days our agents can automate an incredible amount of our daily work. They write code, review pull requests, update documentation, and poke at APIs through a handful of little CLIs. Yet for many of us, email is still one of the last manual parts of the workflow. And that's odd, because email is exactly the kind of tedious, repetitive triage I'd love to hand off.",[11,1274,1275],{},"The problem was never the agent, it was the interface. A full mail client is a GUI, useless to a clanker. A raw IMAP library means the agent has to write code every time it wants to ask a simple question. Neither one lets you hand the agent a command and get back something it can read.",[11,1277,1278,1279,1281,1282,1284],{},"That's the gap ",[59,1280,61],{}," fills, and it's why the two boring-sounding features up top are actually the important ones: zero-install ",[59,1283,75],{}," and JSON on stdout aren't just convenient for you at the terminal. They're the exact two things an agent needs to use a tool with no help from anyone.",[11,1286,1287,1288,1291],{},"Once your agent runs ",[59,1289,1290],{},"npx climail",", it learns how to use it just by reading the help text. No MCP server to stand up, no SDK to wire in.",[11,1293,1294],{},"You can say something like:",[1296,1297,1298],"blockquote",{},[11,1299,1300],{},"Go through my unread mail. Label anything from a customer as \"Triaged\", and for the ones reporting a bug, draft a short reply letting them know we're on it.",[11,1302,1303,1304,1306,1307,1309,1310,1313,1314,1316],{},"And it will just... do it, by chaining the commands you saw above: ",[59,1305,634],{}," to find them, ",[59,1308,716],{}," to understand each one, ",[59,1311,1312],{},"label"," to file them, ",[59,1315,1067],{}," to stage the answers, checking the JSON at every step before taking the next.",[115,1318,1320],{"id":1319},"the-skill","The Skill",[11,1322,1323],{},"Do you like agent skills? We got you:",[126,1325,1327],{"className":128,"code":1326,"language":130,"meta":34,"style":34},"npx skills add nicodevs/climail\n",[59,1328,1329],{"__ignoreMap":34},[134,1330,1331,1333,1336,1339],{"class":136,"line":137},[134,1332,75],{"class":140},[134,1334,1335],{"class":143}," skills",[134,1337,1338],{"class":143}," add",[134,1340,1341],{"class":143}," nicodevs/climail\n",[99,1343,1345],{"id":1344},"in-closing","In Closing",[11,1347,1348,1349,1351],{},"I hope you enjoy ",[59,1350,61],{},". If you have any comments or questions, feel free to create an issue. And throw in a star on GitHub, if you want to help!",[11,1353,1354],{},"See you next time.",[1356,1357,1358],"style",{},"html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .susgL, html code.shiki .susgL{--shiki-default:#CFCFC2}html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .snpHw, html code.shiki .snpHw{--shiki-default:#88846F}",{"title":34,"searchDepth":35,"depth":35,"links":1360},[1361,1367,1370,1371,1374],{"id":101,"depth":35,"text":102,"children":1362},[1363,1364,1365,1366],{"id":117,"depth":169,"text":118},{"id":191,"depth":169,"text":192},{"id":234,"depth":169,"text":235},{"id":533,"depth":169,"text":534},{"id":554,"depth":35,"text":555,"children":1368},[1369],{"id":815,"depth":169,"text":816},{"id":1090,"depth":35,"text":1091},{"id":1265,"depth":35,"text":1266,"children":1372},[1373],{"id":1319,"depth":169,"text":1320},{"id":1344,"depth":35,"text":1345},"2026-07-15","A clanker-friendly command line email client. Read, write, and organize your inbox from the terminal, with clean JSON that any AI agent can pick up.","/img/blog/climail-the-email-client-for-your-agent.png",{},"/blog/climail-the-email-client-for-your-agent",{"title":50,"description":1376},"blog/climail-the-email-client-for-your-agent","jZNqP6GquUg4sN1I-ki5zP8-RDPfr-xmKo1gaEXateg",{"id":1384,"title":1385,"body":1386,"category":37,"date":2348,"description":2349,"draft":39,"extension":40,"image":2350,"meta":2351,"navigation":43,"path":2352,"seo":2353,"stem":2354,"tighten":39,"__hash__":2355},"blog/blog/advanced-agentic-workflows-with-stacked-skills.md","Advanced Agentic Workflows With Stacked Skills",{"type":8,"value":1387,"toc":2339},[1388,1391,1396,1399,1402,1407,1410,1413,1417,1428,1431,1450,1457,1463,1467,1470,1473,1482,1485,1502,1505,1508,1551,1554,1596,1599,1602,1606,1613,1620,1725,1728,1731,1738,2114,2118,2121,2231,2240,2243,2247,2250,2255,2258,2265,2269,2272,2320,2323,2327,2330,2333,2336],[11,1389,1390],{},"We all know that simply asking a clanker:",[1296,1392,1393],{},[11,1394,1395],{},"Build a million-dollar app. Make no mistakes.",[11,1397,1398],{},"...won't work as magically as you'd expect, no matter what the vibe coders on Twitter say. However, there are far more effective ways to automate workflows for our agents.",[11,1400,1401],{},"Imagine having a list of 12 to-do cards in Trello and simply saying:",[1296,1403,1404],{},[11,1405,1406],{},"Tackle all my pending tasks.",[11,1408,1409],{},"...then letting the agent run until you have 12 PRs ready for review. Pretty cool, right?",[11,1411,1412],{},"Today, we'll explore how to create skills, compose them together, and build exactly that kind of workflow.",[99,1414,1416],{"id":1415},"skills-can-call-other-skills","Skills Can Call Other Skills",[11,1418,1419,1420,1424,1425],{},"In ",[21,1421,1423],{"href":1422},"/blog/skills-i-know-kung-fu","the last post"," I built a skill to ",[56,1426,1427],{},"automate creating PRs in GitHub.",[11,1429,1430],{},"What's great is that not only I can run it in my agent session, but also call it from other skills. A skill can call another skill by naming its slash command as a step in its own instructions, the same way you would type it into the prompt.",[126,1432,1436],{"className":1433,"code":1434,"filename":1435,"language":40,"meta":34,"style":34},"language-md shiki shiki-themes monokai","- Once you are done, create a PR using `/github-pr main`\n","SKILL.md",[59,1437,1438],{"__ignoreMap":34},[134,1439,1440,1443,1446],{"class":136,"line":137},[134,1441,1442],{"class":140},"-",[134,1444,1445],{"class":265}," Once you are done, create a PR using ",[134,1447,1449],{"class":1448},"szGx7","`/github-pr main`\n",[11,1451,1452,1453,1456],{},"When reading that line, the agent will run that ",[59,1454,1455],{},"/github-pr"," skill. There is no API or plugin system to set up, the agent is smart enough to figure it out.",[11,1458,1459,1460,1462],{},"That may sound like a small detail, but it changes how you design skills: instead of one large ",[59,1461,1435],{}," that tries to handle an entire process, you can write skills that each do one thing well and let them delegate the rest.",[99,1464,1466],{"id":1465},"the-goal","The Goal",[11,1468,1469],{},"Let's explore a complex workflow to put this in practice.",[11,1471,1472],{},"I'd love to leave my agent running in the background, tackling all the pending items of a given project.",[11,1474,1475,1476,1481],{},"How can we organize the tasks? Well, perhaps we can use ",[21,1477,1480],{"href":1478,"rel":1479},"https://trello.com",[25],"Trello",". A simple board will do: \"To Do\", \"Doing\", \"Under Review\" and \"Done\".",[11,1483,1484],{},"The full agentic workflow will be:",[64,1486,1487,1490,1493,1496,1499],{},[67,1488,1489],{},"Pick the first card from the \"To Do\" list",[67,1491,1492],{},"Move the card to \"Doing\"",[67,1494,1495],{},"Complete the task",[67,1497,1498],{},"Create a PR",[67,1500,1501],{},"Move the card to \"Under Review\"",[11,1503,1504],{},"We'll keep things simple: each card will represent a feature, a concrete and rather small amount of work. Each card will have its own, dedicated PR.",[11,1506,1507],{},"If we are building an online shop, a card could be:",[126,1509,1511],{"className":1433,"code":1510,"language":40,"meta":34,"style":34},"# Implement Product Search\n\n- Add a search input to the product listing page\n- As the user types, filter the list of products by name\n- If no products match the search, display a \"No products found\" message\n- Clearing the search input should restore the full product list\n",[59,1512,1513,1519,1523,1530,1537,1544],{"__ignoreMap":34},[134,1514,1515],{"class":136,"line":137},[134,1516,1518],{"class":1517},"ssRA_","# Implement Product Search\n",[134,1520,1521],{"class":136,"line":35},[134,1522,1160],{"emptyLinePlaceholder":43},[134,1524,1525,1527],{"class":136,"line":169},[134,1526,1442],{"class":140},[134,1528,1529],{"class":265}," Add a search input to the product listing page\n",[134,1531,1532,1534],{"class":136,"line":297},[134,1533,1442],{"class":140},[134,1535,1536],{"class":265}," As the user types, filter the list of products by name\n",[134,1538,1539,1541],{"class":136,"line":310},[134,1540,1442],{"class":140},[134,1542,1543],{"class":265}," If no products match the search, display a \"No products found\" message\n",[134,1545,1546,1548],{"class":136,"line":323},[134,1547,1442],{"class":140},[134,1549,1550],{"class":265}," Clearing the search input should restore the full product list\n",[11,1552,1553],{},"Looks like a good unit of work, right? Another card could be:",[126,1555,1557],{"className":1433,"code":1556,"language":40,"meta":34,"style":34},"# Implement Product Categories\n\n- Display the list of available categories on the product listing page\n- Selecting a category should filter the displayed products\n- The user can clear the selected category to view all products again\n- The selected category should remain active while navigating the product list\n",[59,1558,1559,1564,1568,1575,1582,1589],{"__ignoreMap":34},[134,1560,1561],{"class":136,"line":137},[134,1562,1563],{"class":1517},"# Implement Product Categories\n",[134,1565,1566],{"class":136,"line":35},[134,1567,1160],{"emptyLinePlaceholder":43},[134,1569,1570,1572],{"class":136,"line":169},[134,1571,1442],{"class":140},[134,1573,1574],{"class":265}," Display the list of available categories on the product listing page\n",[134,1576,1577,1579],{"class":136,"line":297},[134,1578,1442],{"class":140},[134,1580,1581],{"class":265}," Selecting a category should filter the displayed products\n",[134,1583,1584,1586],{"class":136,"line":310},[134,1585,1442],{"class":140},[134,1587,1588],{"class":265}," The user can clear the selected category to view all products again\n",[134,1590,1591,1593],{"class":136,"line":323},[134,1592,1442],{"class":140},[134,1594,1595],{"class":265}," The selected category should remain active while navigating the product list\n",[11,1597,1598],{},"And so on and so forth!",[11,1600,1601],{},"We already have a skill that takes care of interacting with GitHub. Now, we only need one to interact with Trello.",[99,1603,1605],{"id":1604},"the-trello-skill","The Trello Skill",[11,1607,1608,1609,1612],{},"Let's create this simple skill to teach the agent how to work with Trello's API. Of course, the reasoning model can ",[110,1610,1611],{},"figure it out,"," but it's faster (and less token consuming) to steer it in the right direction.",[11,1614,1615,1616,1619],{},"First, we need a file with the credentials and the IDs of the board and lists we want to work with. In the root of our project, we can save a ",[59,1617,1618],{},"trello.json"," that would look something like this:",[126,1621,1623],{"className":256,"code":1622,"filename":1618,"language":258,"meta":34,"style":34},"{\n  \"apiKey\": \"...\",\n  \"apiToken\": \"...\",\n  \"board\": \"\u003Cboard-id>\",\n  \"lists\": {\n    \"To Do\": \"\u003Clist-id>\",\n    \"Doing\": \"\u003Clist-id>\",\n    \"Under Review\": \"\u003Clist-id>\",\n    \"Done\": \"\u003Clist-id>\"\n  }\n}\n",[59,1624,1625,1629,1641,1652,1664,1672,1684,1695,1706,1716,1721],{"__ignoreMap":34},[134,1626,1627],{"class":136,"line":137},[134,1628,266],{"class":265},[134,1630,1631,1634,1636,1639],{"class":136,"line":35},[134,1632,1633],{"class":271},"  \"apiKey\"",[134,1635,275],{"class":265},[134,1637,1638],{"class":291},"\"...\"",[134,1640,281],{"class":265},[134,1642,1643,1646,1648,1650],{"class":136,"line":169},[134,1644,1645],{"class":271},"  \"apiToken\"",[134,1647,275],{"class":265},[134,1649,1638],{"class":291},[134,1651,281],{"class":265},[134,1653,1654,1657,1659,1662],{"class":136,"line":297},[134,1655,1656],{"class":271},"  \"board\"",[134,1658,275],{"class":265},[134,1660,1661],{"class":291},"\"\u003Cboard-id>\"",[134,1663,281],{"class":265},[134,1665,1666,1669],{"class":136,"line":310},[134,1667,1668],{"class":271},"  \"lists\"",[134,1670,1671],{"class":265},": {\n",[134,1673,1674,1677,1679,1682],{"class":136,"line":323},[134,1675,1676],{"class":271},"    \"To Do\"",[134,1678,275],{"class":265},[134,1680,1681],{"class":291},"\"\u003Clist-id>\"",[134,1683,281],{"class":265},[134,1685,1686,1689,1691,1693],{"class":136,"line":332},[134,1687,1688],{"class":271},"    \"Doing\"",[134,1690,275],{"class":265},[134,1692,1681],{"class":291},[134,1694,281],{"class":265},[134,1696,1697,1700,1702,1704],{"class":136,"line":338},[134,1698,1699],{"class":271},"    \"Under Review\"",[134,1701,275],{"class":265},[134,1703,1681],{"class":291},[134,1705,281],{"class":265},[134,1707,1708,1711,1713],{"class":136,"line":351},[134,1709,1710],{"class":271},"    \"Done\"",[134,1712,275],{"class":265},[134,1714,1715],{"class":291},"\"\u003Clist-id>\"\n",[134,1717,1718],{"class":136,"line":364},[134,1719,1720],{"class":265},"  }\n",[134,1722,1723],{"class":136,"line":377},[134,1724,508],{"class":265},[11,1726,1727],{},"Of course, remember to add that file to gitignore so you don't leak your credentials.",[11,1729,1730],{},"Now, we only need to tell our agent how to operate with Trello so it knows exactly what to do when we ask it to \"create a new card\" or \"move this card to Done\".",[11,1732,1733,1734,1737],{},"This is how the ",[59,1735,1736],{},"/trello"," skill could look like:",[126,1739,1742],{"className":1433,"code":1740,"filename":1741,"language":40,"meta":34,"style":34},"---\nname: trello\ndescription: Interacts with a Trello board. Triggered by \"/trello\".\n---\n\nUse this skill whenever the user asks you to read, create, update, delete, or move cards.\n\n## Credentials\n\nRead board credentials from `trello.json` in the project root:\n\n```json\n{\n  \"apiKey\": \"...\",\n  \"apiToken\": \"...\",\n  \"board\": \"\u003Cboard-id>\",\n  \"lists\": {\n    \"To Do\": \"\u003Clist-id>\",\n    \"Doing\": \"\u003Clist-id>\",\n    \"Under Review\": \"\u003Clist-id>\",\n    \"Done\": \"\u003Clist-id>\"\n  }\n}\n```\n\n## Read list\n\n`curl -s \"https://api.trello.com/1/lists/\u003Clist-id>/cards?key=\u003CapiKey>&token=\u003CapiToken>\"`\n\n## Create card\n\nThe card name is the title and the description holds the body. Use `--data-urlencode` so spaces and line breaks survive:\n\n`curl -s -X POST \"https://api.trello.com/1/cards?key=\u003CapiKey>&token=\u003CapiToken>\" --data-urlencode \"idList=\u003Clist-id>\" --data-urlencode \"name=\u003Ccard-title>\" --data-urlencode \"desc=\u003Ccard-body>\"`\n\n## Move card\n\n`curl -s -X PUT \"https://api.trello.com/1/cards/\u003Ccard-id>?key=\u003CapiKey>&token=\u003CapiToken>&idList=\u003Clist-id>\"`\n\n## Update card\n\nSame endpoint as moving, just pass whichever fields changed:\n\n`curl -s -X PUT \"https://api.trello.com/1/cards/\u003Ccard-id>?key=\u003CapiKey>&token=\u003CapiToken>\" --data-urlencode \"name=\u003Ccard-title>\" --data-urlencode \"desc=\u003Ccard-body>\"`\n\n## Delete card\n\n`curl -s -X DELETE \"https://api.trello.com/1/cards/\u003Ccard-id>?key=\u003CapiKey>&token=\u003CapiToken>\"`\n\n## Attach a URL\n\nAttach a link to a card, for example the PR you just opened:\n\n`curl -s -X POST \"https://api.trello.com/1/cards/\u003Ccard-id>/attachments?key=\u003CapiKey>&token=\u003CapiToken>\" --data-urlencode \"url=https://google.com\"`\n\n## Error handling\n\n- If `trello.json` is missing or malformed, stop and tell the user.\n- If the Trello API returns a non-2xx status, show the error and stop.\n",".claude/skills/trello/SKILL.md",[59,1743,1744,1749,1759,1769,1773,1777,1782,1786,1791,1795,1806,1810,1815,1819,1829,1839,1849,1855,1865,1875,1885,1893,1897,1901,1906,1911,1917,1922,1928,1933,1939,1944,1956,1961,1967,1972,1978,1983,1989,1994,2000,2005,2011,2016,2022,2027,2033,2038,2044,2049,2055,2060,2066,2071,2077,2082,2088,2093,2106],{"__ignoreMap":34},[134,1745,1746],{"class":136,"line":137},[134,1747,1748],{"class":265},"---\n",[134,1750,1751,1754,1756],{"class":136,"line":35},[134,1752,1753],{"class":1112},"name",[134,1755,275],{"class":265},[134,1757,1758],{"class":143},"trello\n",[134,1760,1761,1764,1766],{"class":136,"line":169},[134,1762,1763],{"class":1112},"description",[134,1765,275],{"class":265},[134,1767,1768],{"class":143},"Interacts with a Trello board. Triggered by \"/trello\".\n",[134,1770,1771],{"class":136,"line":297},[134,1772,1748],{"class":265},[134,1774,1775],{"class":136,"line":310},[134,1776,1160],{"emptyLinePlaceholder":43},[134,1778,1779],{"class":136,"line":323},[134,1780,1781],{"class":265},"Use this skill whenever the user asks you to read, create, update, delete, or move cards.\n",[134,1783,1784],{"class":136,"line":332},[134,1785,1160],{"emptyLinePlaceholder":43},[134,1787,1788],{"class":136,"line":338},[134,1789,1790],{"class":1517},"## Credentials\n",[134,1792,1793],{"class":136,"line":351},[134,1794,1160],{"emptyLinePlaceholder":43},[134,1796,1797,1800,1803],{"class":136,"line":364},[134,1798,1799],{"class":265},"Read board credentials from ",[134,1801,1802],{"class":1448},"`trello.json`",[134,1804,1805],{"class":265}," in the project root:\n",[134,1807,1808],{"class":136,"line":377},[134,1809,1160],{"emptyLinePlaceholder":43},[134,1811,1812],{"class":136,"line":390},[134,1813,1814],{"class":265},"```json\n",[134,1816,1817],{"class":136,"line":403},[134,1818,266],{"class":265},[134,1820,1821,1823,1825,1827],{"class":136,"line":414},[134,1822,1633],{"class":271},[134,1824,275],{"class":265},[134,1826,1638],{"class":291},[134,1828,281],{"class":265},[134,1830,1831,1833,1835,1837],{"class":136,"line":420},[134,1832,1645],{"class":271},[134,1834,275],{"class":265},[134,1836,1638],{"class":291},[134,1838,281],{"class":265},[134,1840,1841,1843,1845,1847],{"class":136,"line":425},[134,1842,1656],{"class":271},[134,1844,275],{"class":265},[134,1846,1661],{"class":291},[134,1848,281],{"class":265},[134,1850,1851,1853],{"class":136,"line":436},[134,1852,1668],{"class":271},[134,1854,1671],{"class":265},[134,1856,1857,1859,1861,1863],{"class":136,"line":448},[134,1858,1676],{"class":271},[134,1860,275],{"class":265},[134,1862,1681],{"class":291},[134,1864,281],{"class":265},[134,1866,1867,1869,1871,1873],{"class":136,"line":460},[134,1868,1688],{"class":271},[134,1870,275],{"class":265},[134,1872,1681],{"class":291},[134,1874,281],{"class":265},[134,1876,1877,1879,1881,1883],{"class":136,"line":472},[134,1878,1699],{"class":271},[134,1880,275],{"class":265},[134,1882,1681],{"class":291},[134,1884,281],{"class":265},[134,1886,1887,1889,1891],{"class":136,"line":484},[134,1888,1710],{"class":271},[134,1890,275],{"class":265},[134,1892,1715],{"class":291},[134,1894,1895],{"class":136,"line":493},[134,1896,1720],{"class":265},[134,1898,1899],{"class":136,"line":499},[134,1900,508],{"class":265},[134,1902,1903],{"class":136,"line":505},[134,1904,1905],{"class":265},"```\n",[134,1907,1909],{"class":136,"line":1908},25,[134,1910,1160],{"emptyLinePlaceholder":43},[134,1912,1914],{"class":136,"line":1913},26,[134,1915,1916],{"class":1517},"## Read list\n",[134,1918,1920],{"class":136,"line":1919},27,[134,1921,1160],{"emptyLinePlaceholder":43},[134,1923,1925],{"class":136,"line":1924},28,[134,1926,1927],{"class":1448},"`curl -s \"https://api.trello.com/1/lists/\u003Clist-id>/cards?key=\u003CapiKey>&token=\u003CapiToken>\"`\n",[134,1929,1931],{"class":136,"line":1930},29,[134,1932,1160],{"emptyLinePlaceholder":43},[134,1934,1936],{"class":136,"line":1935},30,[134,1937,1938],{"class":1517},"## Create card\n",[134,1940,1942],{"class":136,"line":1941},31,[134,1943,1160],{"emptyLinePlaceholder":43},[134,1945,1947,1950,1953],{"class":136,"line":1946},32,[134,1948,1949],{"class":265},"The card name is the title and the description holds the body. Use ",[134,1951,1952],{"class":1448},"`--data-urlencode`",[134,1954,1955],{"class":265}," so spaces and line breaks survive:\n",[134,1957,1959],{"class":136,"line":1958},33,[134,1960,1160],{"emptyLinePlaceholder":43},[134,1962,1964],{"class":136,"line":1963},34,[134,1965,1966],{"class":1448},"`curl -s -X POST \"https://api.trello.com/1/cards?key=\u003CapiKey>&token=\u003CapiToken>\" --data-urlencode \"idList=\u003Clist-id>\" --data-urlencode \"name=\u003Ccard-title>\" --data-urlencode \"desc=\u003Ccard-body>\"`\n",[134,1968,1970],{"class":136,"line":1969},35,[134,1971,1160],{"emptyLinePlaceholder":43},[134,1973,1975],{"class":136,"line":1974},36,[134,1976,1977],{"class":1517},"## Move card\n",[134,1979,1981],{"class":136,"line":1980},37,[134,1982,1160],{"emptyLinePlaceholder":43},[134,1984,1986],{"class":136,"line":1985},38,[134,1987,1988],{"class":1448},"`curl -s -X PUT \"https://api.trello.com/1/cards/\u003Ccard-id>?key=\u003CapiKey>&token=\u003CapiToken>&idList=\u003Clist-id>\"`\n",[134,1990,1992],{"class":136,"line":1991},39,[134,1993,1160],{"emptyLinePlaceholder":43},[134,1995,1997],{"class":136,"line":1996},40,[134,1998,1999],{"class":1517},"## Update card\n",[134,2001,2003],{"class":136,"line":2002},41,[134,2004,1160],{"emptyLinePlaceholder":43},[134,2006,2008],{"class":136,"line":2007},42,[134,2009,2010],{"class":265},"Same endpoint as moving, just pass whichever fields changed:\n",[134,2012,2014],{"class":136,"line":2013},43,[134,2015,1160],{"emptyLinePlaceholder":43},[134,2017,2019],{"class":136,"line":2018},44,[134,2020,2021],{"class":1448},"`curl -s -X PUT \"https://api.trello.com/1/cards/\u003Ccard-id>?key=\u003CapiKey>&token=\u003CapiToken>\" --data-urlencode \"name=\u003Ccard-title>\" --data-urlencode \"desc=\u003Ccard-body>\"`\n",[134,2023,2025],{"class":136,"line":2024},45,[134,2026,1160],{"emptyLinePlaceholder":43},[134,2028,2030],{"class":136,"line":2029},46,[134,2031,2032],{"class":1517},"## Delete card\n",[134,2034,2036],{"class":136,"line":2035},47,[134,2037,1160],{"emptyLinePlaceholder":43},[134,2039,2041],{"class":136,"line":2040},48,[134,2042,2043],{"class":1448},"`curl -s -X DELETE \"https://api.trello.com/1/cards/\u003Ccard-id>?key=\u003CapiKey>&token=\u003CapiToken>\"`\n",[134,2045,2047],{"class":136,"line":2046},49,[134,2048,1160],{"emptyLinePlaceholder":43},[134,2050,2052],{"class":136,"line":2051},50,[134,2053,2054],{"class":1517},"## Attach a URL\n",[134,2056,2058],{"class":136,"line":2057},51,[134,2059,1160],{"emptyLinePlaceholder":43},[134,2061,2063],{"class":136,"line":2062},52,[134,2064,2065],{"class":265},"Attach a link to a card, for example the PR you just opened:\n",[134,2067,2069],{"class":136,"line":2068},53,[134,2070,1160],{"emptyLinePlaceholder":43},[134,2072,2074],{"class":136,"line":2073},54,[134,2075,2076],{"class":1448},"`curl -s -X POST \"https://api.trello.com/1/cards/\u003Ccard-id>/attachments?key=\u003CapiKey>&token=\u003CapiToken>\" --data-urlencode \"url=https://google.com\"`\n",[134,2078,2080],{"class":136,"line":2079},55,[134,2081,1160],{"emptyLinePlaceholder":43},[134,2083,2085],{"class":136,"line":2084},56,[134,2086,2087],{"class":1517},"## Error handling\n",[134,2089,2091],{"class":136,"line":2090},57,[134,2092,1160],{"emptyLinePlaceholder":43},[134,2094,2096,2098,2101,2103],{"class":136,"line":2095},58,[134,2097,1442],{"class":140},[134,2099,2100],{"class":265}," If ",[134,2102,1802],{"class":1448},[134,2104,2105],{"class":265}," is missing or malformed, stop and tell the user.\n",[134,2107,2109,2111],{"class":136,"line":2108},59,[134,2110,1442],{"class":140},[134,2112,2113],{"class":265}," If the Trello API returns a non-2xx status, show the error and stop.\n",[99,2115,2117],{"id":2116},"the-orchestrator-skill","The Orchestrator Skill",[11,2119,2120],{},"Great! Now that we have skills that know how to deal with Trello and GitHub, we can create an orchestrator skill that makes use of both.",[126,2122,2125],{"className":1433,"code":2123,"filename":2124,"language":40,"meta":34,"style":34},"---\nname: tackle-task\ndescription: Pick the first card in the Trello \"To Do\" list, build the feature it describes, open a PR, and move the card to \"Under Review\".\n---\n\n## Steps\n\n1. Use `/trello` to read the first card in \"To Do\". If the list is empty, stop here.\n2. Use `/trello` to move that card to \"Doing\".\n3. Treat the card's title and description as the feature spec and build it, following the conventions already in the codebase.\n4. Use `/github-pr main` to open the pull request. If this card builds on an earlier one, target that branch instead so the PRs stack: `/github-pr \u003Cbase-branch>`.\n5. Use `/trello` to move the card to \"Under Review\" and attach the PR link.\n",".claude/skills/tackle-task/SKILL.md",[59,2126,2127,2131,2140,2149,2153,2157,2162,2166,2180,2192,2200,2219],{"__ignoreMap":34},[134,2128,2129],{"class":136,"line":137},[134,2130,1748],{"class":265},[134,2132,2133,2135,2137],{"class":136,"line":35},[134,2134,1753],{"class":1112},[134,2136,275],{"class":265},[134,2138,2139],{"class":143},"tackle-task\n",[134,2141,2142,2144,2146],{"class":136,"line":169},[134,2143,1763],{"class":1112},[134,2145,275],{"class":265},[134,2147,2148],{"class":143},"Pick the first card in the Trello \"To Do\" list, build the feature it describes, open a PR, and move the card to \"Under Review\".\n",[134,2150,2151],{"class":136,"line":297},[134,2152,1748],{"class":265},[134,2154,2155],{"class":136,"line":310},[134,2156,1160],{"emptyLinePlaceholder":43},[134,2158,2159],{"class":136,"line":323},[134,2160,2161],{"class":1517},"## Steps\n",[134,2163,2164],{"class":136,"line":332},[134,2165,1160],{"emptyLinePlaceholder":43},[134,2167,2168,2171,2174,2177],{"class":136,"line":338},[134,2169,2170],{"class":140},"1.",[134,2172,2173],{"class":265}," Use ",[134,2175,2176],{"class":1448},"`/trello`",[134,2178,2179],{"class":265}," to read the first card in \"To Do\". If the list is empty, stop here.\n",[134,2181,2182,2185,2187,2189],{"class":136,"line":351},[134,2183,2184],{"class":140},"2.",[134,2186,2173],{"class":265},[134,2188,2176],{"class":1448},[134,2190,2191],{"class":265}," to move that card to \"Doing\".\n",[134,2193,2194,2197],{"class":136,"line":364},[134,2195,2196],{"class":140},"3.",[134,2198,2199],{"class":265}," Treat the card's title and description as the feature spec and build it, following the conventions already in the codebase.\n",[134,2201,2202,2205,2207,2210,2213,2216],{"class":136,"line":377},[134,2203,2204],{"class":140},"4.",[134,2206,2173],{"class":265},[134,2208,2209],{"class":1448},"`/github-pr main`",[134,2211,2212],{"class":265}," to open the pull request. If this card builds on an earlier one, target that branch instead so the PRs stack: ",[134,2214,2215],{"class":1448},"`/github-pr \u003Cbase-branch>`",[134,2217,2218],{"class":265},".\n",[134,2220,2221,2224,2226,2228],{"class":136,"line":390},[134,2222,2223],{"class":140},"5.",[134,2225,2173],{"class":265},[134,2227,2176],{"class":1448},[134,2229,2230],{"class":265}," to move the card to \"Under Review\" and attach the PR link.\n",[11,2232,2233,2234,2236,2237,2239],{},"As you can see, this skill never explains how to name a branch, write a PR body, or talk to GitHub. It delegates all of that to ",[59,2235,1455],{},". Same goes for the Trello operations: saying \"move card to Doing\" is enough, since ",[59,2238,1736],{}," knows how to do it.",[11,2241,2242],{},"Each skill stays small, and neither has to absorb the other's job.",[99,2244,2246],{"id":2245},"running-the-whole-board","Running the Whole Board",[11,2248,2249],{},"Because the workflow lives in skills rather than in my head, scaling from one card to the entire backlog takes a single instruction:",[1296,2251,2252],{},[11,2253,2254],{},"Sequentially tackle every task in our Trello.",[11,2256,2257],{},"That's it! That instruction makes the agent work until we have no more cards in \"To Do\".",[11,2259,2260,2261,2264],{},"The end result is a bunch of PRs in GitHub, ready for human review. Yeah, ",[110,2262,2263],{},"human review:"," I think it's important to take a moment to read the code the clanker generated. We saved so much time already (not only in the coding aspect, but all the ceremony that goes with it: writing PRs, moving cards, etc.) that taking a look can't hurt anyone... and can save you from shipping slop.",[99,2266,2268],{"id":2267},"designing-skills-that-stack","Designing Skills That Stack",[11,2270,2271],{},"A few principles made the difference between skills that compose cleanly and skills that fight each other:",[64,2273,2274,2290,2303,2311],{},[67,2275,2276,2279,2280,2282,2283,2285,2286,2289],{},[56,2277,2278],{},"One job per skill."," ",[59,2281,1455],{}," only knows how to open a PR, and ",[59,2284,1736],{}," only knows how to talk to the board. That's exactly why ",[59,2287,2288],{},"/tackle-task"," can lean on both without dragging their guts into its own instructions.",[67,2291,2292,2295,2296,1068,2299,2302],{},[56,2293,2294],{},"Give each skill a small handle to grab."," The base-branch argument is the whole reason ",[59,2297,2298],{},"/github-pr main",[59,2300,2301],{},"/github-pr some-feature"," both work. One little parameter turns a fixed command into a building block.",[67,2304,2305,2279,2308,2310],{},[56,2306,2307],{},"Let the orchestrator do the deciding.",[59,2309,2288],{}," owns the order of things and the \"does this card stack on another?\" call. The skills it invokes never need to know that context exists.",[67,2312,2313,2316,2317,2319],{},[56,2314,2315],{},"Stop when something looks off."," An empty \"To Do\" list, a missing ",[59,2318,1618],{},", a non-2xx from the API: any of those halts the run instead of quietly plowing ahead and making a mess you have to untangle later.",[11,2321,2322],{},"None of this depends on an exotic feature. It's the same design thinking you already apply to code, pointed at the skills your agent runs.",[99,2324,2326],{"id":2325},"where-this-goes","Where This Goes",[11,2328,2329],{},"Once you start seeing skills as stackable recipes, it's hard not to notice more places to apply them.",[11,2331,2332],{},"The one I'm building now checks my inbox, reads emails from my app users, turns their requests into Trello cards, works on them, opens a PR for each one, and drafts an email reply for me to send later.",[11,2334,2335],{},"Here's a challenge for you: think about what you can automate with skills. Build it, and let me know how it goes!",[1356,2337,2338],{},"html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .szGx7, html code.shiki .szGx7{--shiki-default:#FD971F}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .ssRA_, html code.shiki .ssRA_{--shiki-default:#A6E22E;--shiki-default-font-weight:bold}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .susgL, html code.shiki .susgL{--shiki-default:#CFCFC2}html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}",{"title":34,"searchDepth":35,"depth":35,"links":2340},[2341,2342,2343,2344,2345,2346,2347],{"id":1415,"depth":35,"text":1416},{"id":1465,"depth":35,"text":1466},{"id":1604,"depth":35,"text":1605},{"id":2116,"depth":35,"text":2117},{"id":2245,"depth":35,"text":2246},{"id":2267,"depth":35,"text":2268},{"id":2325,"depth":35,"text":2326},"2026-07-09","A single skill removes one repetitive task. Stacking skills, letting one call another, turns a backlog into a workflow your agent can run end to end.","/img/blog/advanced-agentic-workflows-with-stacked-skills.png",{},"/blog/advanced-agentic-workflows-with-stacked-skills",{"title":1385,"description":2349},"blog/advanced-agentic-workflows-with-stacked-skills","dhbCP-c8nD_KIMpxzUk_rO96Kxg8PL8uJl3vdqTIGwM",{"id":2357,"title":2358,"body":2359,"category":37,"date":3684,"description":3685,"draft":39,"extension":40,"image":3686,"meta":3687,"navigation":43,"path":1422,"seo":3688,"stem":3689,"tighten":39,"__hash__":3690},"blog/blog/skills-i-know-kung-fu.md","Skills: I Know Kung Fu!",{"type":8,"value":2360,"toc":3674},[2361,2372,2379,2382,2385,2394,2397,2402,2412,2416,2419,2422,2436,2441,2454,2457,2498,2501,2519,2528,2534,2537,2541,2548,2552,2555,2558,2575,2578,2593,2601,2606,2798,2808,2824,2827,2830,2834,2837,2842,2845,2885,2888,2898,2904,2908,2915,2918,2958,2965,2968,2972,2978,2981,3001,3004,3010,3016,3035,3038,3357,3367,3373,3568,3575,3634,3640,3643,3645,3647,3650,3653,3662,3668,3671],[11,2362,2363,2364,2367,2368,2371],{},"There's this famous scene in ",[110,2365,2366],{},"The Matrix"," where Neo gets a ",[110,2369,2370],{},"software update"," that instantly teaches him Kung Fu. I'd love that in real life! Maybe then I could finally play the keyboard without sounding like a cat walked across it.",[11,2373,2374,2375,2378],{},"Unfortunately, we don't have that. But we do have ",[56,2376,2377],{},"skills for our agents",", which, while not nearly as cool, can still save you a big chunk of time.",[11,2380,2381],{},"Think about everything you asked Claude to do this week. Was there one request you found yourself repeating over and over, using almost the exact same prompt?",[11,2383,2384],{},"For me, it's creating GitHub PRs.",[1296,2386,2387],{},[11,2388,2389,2390,2393],{},"Open a PR. Keep the description short. No emojis. Don't list yourself as a co-author. Don't use the word ",[110,2391,2392],{},"seamlessly","...",[11,2395,2396],{},"I'd type it, the agent would do a decent job, and a couple of hours later I'd find myself typing the exact same thing again for the next branch.",[1296,2398,2399],{},[11,2400,2401],{},"Open a PR. Short description. No emojis...",[11,2403,2404,2405,2408,2409],{},"You know the saying, ",[110,2406,2407],{},"\"This meeting could have been an email\"?"," That's how I feel every time I catch myself repeating a prompt: ",[110,2410,2411],{},"\"This prompt should have been a skill.\"",[99,2413,2415],{"id":2414},"what-is-a-skill","What Is a Skill?",[11,2417,2418],{},"A skill is a recipe you write once so the agent can perform the same task over and over without you narrating every step again.",[11,2420,2421],{},"Unlike a prompt, a skill can bring along everything the task needs: scripts, references, templates, rules, etc.",[11,2423,2424,2425,2428,2429,2432,2433,2435],{},"You create a folder under ",[59,2426,2427],{},".claude/skills/",". Call it whatever you like, for example ",[59,2430,2431],{},"joke",". Inside it, create a ",[59,2434,1435],{}," file.",[11,2437,2438,2440],{},[59,2439,1435],{}," should have two parts:",[64,2442,2443,2451],{},[67,2444,2445,2446,2448,2449,548],{},"A frontmatter block with a ",[59,2447,1753],{}," and a ",[59,2450,1763],{},[67,2452,2453],{},"A markdown body with the actual instructions, written in natural language.",[11,2455,2456],{},"Here's an example:",[126,2458,2461],{"className":1433,"code":2459,"filename":2460,"language":40,"meta":34,"style":34},"---\nname: joke\ndescription: Tells a funny joke.\n---\n\nTell me a funny joke. Extra points if it is about programmers!\n",".claude/skills/joke/SKILL.md",[59,2462,2463,2467,2476,2485,2489,2493],{"__ignoreMap":34},[134,2464,2465],{"class":136,"line":137},[134,2466,1748],{"class":265},[134,2468,2469,2471,2473],{"class":136,"line":35},[134,2470,1753],{"class":1112},[134,2472,275],{"class":265},[134,2474,2475],{"class":143},"joke\n",[134,2477,2478,2480,2482],{"class":136,"line":169},[134,2479,1763],{"class":1112},[134,2481,275],{"class":265},[134,2483,2484],{"class":143},"Tells a funny joke.\n",[134,2486,2487],{"class":136,"line":297},[134,2488,1748],{"class":265},[134,2490,2491],{"class":136,"line":310},[134,2492,1160],{"emptyLinePlaceholder":43},[134,2494,2495],{"class":136,"line":323},[134,2496,2497],{"class":265},"Tell me a funny joke. Extra points if it is about programmers!\n",[11,2499,2500],{},"To use a skill, you can either:",[64,2502,2503,2513],{},[67,2504,2505,2508,2509,2512],{},[56,2506,2507],{},"Invoke it as a command:"," type a slash followed by the skill's name, e.g. ",[59,2510,2511],{},"/joke",", and press enter.",[67,2514,2515,2518],{},[56,2516,2517],{},"Ask in natural language:"," if the skill's description matches what you asked the agent to do, Claude will automatically pick it up. Here, something like \"tell me a joke\" will do.",[1193,2520,2521],{"color":1195,"icon":1196},[11,2522,2523,2524,2527],{},"If you'd rather invoke the skill explicitly every time, add ",[59,2525,2526],{},"disable-model-invocation: true"," to the frontmatter.",[11,2529,2530,2531,2533],{},"The ",[59,2532,1763],{}," is more important than it looks. Whenever you ask Claude to do something, it uses the descriptions of your available skills to decide whether one matches the request.",[11,2535,2536],{},"If the description is vague, the skill rarely gets used. If it's specific, Claude starts reaching for it automatically.",[99,2538,2540],{"id":2539},"a-skill-is-a-folder-not-just-a-file","A Skill Is a Folder, Not Just a File",[11,2542,2543,2544,2547],{},"So far we've only seen a single markdown file, but ",[56,2545,2546],{},"a skill is really the entire folder"," around the markdown file. This folder can contain scripts, reference documents, templates, and anything else the workflow needs.",[115,2549,2551],{"id":2550},"tip-1-skills-can-execute-scripts","Tip 1: Skills Can Execute Scripts",[11,2553,2554],{},"If you describe an action in natural language, the model is usually smart enough to figure out how to do it and write code on the fly to make it happen. Sometimes, though, you want something more predictable.",[11,2556,2557],{},"You can include commands and code snippets in your skills. So instead of saying \"Clear the logs\" and hoping the model does the right thing, you can simply tell it what to run:",[126,2559,2563],{"className":2560,"code":2561,"filename":1435,"language":2562,"meta":34,"style":34},"language-diff shiki shiki-themes monokai","- Clear the logs.\n+ Run `rm -rf ./logs` to clear the logs.\n","diff",[59,2564,2565,2570],{"__ignoreMap":34},[134,2566,2567],{"class":136,"line":137},[134,2568,2569],{"class":1112},"- Clear the logs.\n",[134,2571,2572],{"class":136,"line":35},[134,2573,2574],{"class":140},"+ Run `rm -rf ./logs` to clear the logs.\n",[11,2576,2577],{},"Once those scripts get more than a few lines long, you can move them into their own files.",[11,2579,2580,2581,2584,2585,2588,2589,2592],{},"Say I want a ",[59,2582,2583],{},"video-to-gif"," skill that turns a screen recording into a small GIF I can drop into an issue. To keep the colors from looking terrible, that's really a two-pass job: first build a color palette tailored to the video, then render the GIF using it. That's fiddly enough that I don't want the agent reconstructing the exact ",[59,2586,2587],{},"ffmpeg"," filters every time, so I drop a ",[59,2590,2591],{},"script.sh"," into the skill folder:",[126,2594,2599],{"className":2595,"code":2597,"language":2598,"meta":34},[2596],"language-text","📂 video-to-gif/\n├── 📄 SKILL.md\n└── ▶️ script.sh\n","text",[59,2600,2597],{"__ignoreMap":34},[11,2602,2603,2605],{},[59,2604,2591],{}," scales the video down to 420p, drops it to 12 frames per second, and squeezes it into a 64-color palette:",[126,2607,2611],{"className":2608,"code":2609,"filename":2591,"language":2610,"meta":34,"style":34},"language-shell shiki shiki-themes monokai","#!/usr/bin/env bash\nset -euo pipefail\nINPUT=\"${1:?usage: script.sh \u003Cinput-video>}\"\nOUTPUT=\"${2:-output.gif}\"\n\n# 1. build an optimized palette from the video\nffmpeg -i \"$INPUT\" -vf \"fps=12,scale=-1:420:flags=lanczos,palettegen=max_colors=64\" -y palette.png\n\n# 2. render the gif using that palette\nffmpeg -i \"$INPUT\" -i palette.png -lavfi \"fps=12,scale=-1:420:flags=lanczos[x];[x][1:v]paletteuse\" -y \"$OUTPUT\"\n\n# 3. clean up\nrm -f palette.png\n","shell",[59,2612,2613,2618,2630,2675,2702,2706,2711,2738,2742,2747,2779,2783,2788],{"__ignoreMap":34},[134,2614,2615],{"class":136,"line":137},[134,2616,2617],{"class":1165},"#!/usr/bin/env bash\n",[134,2619,2620,2624,2627],{"class":136,"line":35},[134,2621,2623],{"class":2622},"sYf5A","set",[134,2625,2626],{"class":150}," -euo",[134,2628,2629],{"class":143}," pipefail\n",[134,2631,2632,2635,2637,2640,2644,2647,2649,2652,2655,2657,2660,2663,2666,2669,2672],{"class":136,"line":169},[134,2633,2634],{"class":265},"INPUT",[134,2636,1113],{"class":1112},[134,2638,2639],{"class":143},"\"",[134,2641,2643],{"class":2642},"sW0Xf","${1",[134,2645,2646],{"class":1112},":?",[134,2648,554],{"class":265},[134,2650,2651],{"class":1112},":",[134,2653,2654],{"class":265}," script",[134,2656,548],{"class":143},[134,2658,2659],{"class":265},"sh",[134,2661,2662],{"class":143}," \u003C",[134,2664,2665],{"class":265},"input-video",[134,2667,2668],{"class":143},">",[134,2670,2671],{"class":2642},"}",[134,2673,2674],{"class":143},"\"\n",[134,2676,2677,2680,2682,2684,2687,2690,2693,2695,2698,2700],{"class":136,"line":297},[134,2678,2679],{"class":265},"OUTPUT",[134,2681,1113],{"class":1112},[134,2683,2639],{"class":143},[134,2685,2686],{"class":2642},"${2",[134,2688,2689],{"class":1112},":-",[134,2691,2692],{"class":265},"output",[134,2694,548],{"class":143},[134,2696,2697],{"class":265},"gif",[134,2699,2671],{"class":2642},[134,2701,2674],{"class":143},[134,2703,2704],{"class":136,"line":310},[134,2705,1160],{"emptyLinePlaceholder":43},[134,2707,2708],{"class":136,"line":323},[134,2709,2710],{"class":1165},"# 1. build an optimized palette from the video\n",[134,2712,2713,2715,2718,2721,2724,2726,2729,2732,2735],{"class":136,"line":332},[134,2714,2587],{"class":140},[134,2716,2717],{"class":150}," -i",[134,2719,2720],{"class":143}," \"",[134,2722,2723],{"class":265},"$INPUT",[134,2725,2639],{"class":143},[134,2727,2728],{"class":150}," -vf",[134,2730,2731],{"class":143}," \"fps=12,scale=-1:420:flags=lanczos,palettegen=max_colors=64\"",[134,2733,2734],{"class":150}," -y",[134,2736,2737],{"class":143}," palette.png\n",[134,2739,2740],{"class":136,"line":338},[134,2741,1160],{"emptyLinePlaceholder":43},[134,2743,2744],{"class":136,"line":351},[134,2745,2746],{"class":1165},"# 2. render the gif using that palette\n",[134,2748,2749,2751,2753,2755,2757,2759,2761,2764,2767,2770,2772,2774,2777],{"class":136,"line":364},[134,2750,2587],{"class":140},[134,2752,2717],{"class":150},[134,2754,2720],{"class":143},[134,2756,2723],{"class":265},[134,2758,2639],{"class":143},[134,2760,2717],{"class":150},[134,2762,2763],{"class":143}," palette.png",[134,2765,2766],{"class":150}," -lavfi",[134,2768,2769],{"class":143}," \"fps=12,scale=-1:420:flags=lanczos[x];[x][1:v]paletteuse\"",[134,2771,2734],{"class":150},[134,2773,2720],{"class":143},[134,2775,2776],{"class":265},"$OUTPUT",[134,2778,2674],{"class":143},[134,2780,2781],{"class":136,"line":377},[134,2782,1160],{"emptyLinePlaceholder":43},[134,2784,2785],{"class":136,"line":390},[134,2786,2787],{"class":1165},"# 3. clean up\n",[134,2789,2790,2793,2796],{"class":136,"line":403},[134,2791,2792],{"class":140},"rm",[134,2794,2795],{"class":150}," -f",[134,2797,2737],{"class":143},[11,2799,2800,2801,2803,2804,2807],{},"Then inside ",[59,2802,1435],{},", I simply reference it with ",[59,2805,2806],{},"${CLAUDE_SKILL_DIR}"," so the path works no matter where the skill is invoked from:",[126,2809,2811],{"className":1433,"code":2810,"language":40,"meta":34,"style":34},"Run `${CLAUDE_SKILL_DIR}/script.sh \u003Crecording.mov>` to produce the GIF.\n",[59,2812,2813],{"__ignoreMap":34},[134,2814,2815,2818,2821],{"class":136,"line":137},[134,2816,2817],{"class":265},"Run ",[134,2819,2820],{"class":1448},"`${CLAUDE_SKILL_DIR}/script.sh \u003Crecording.mov>`",[134,2822,2823],{"class":265}," to produce the GIF.\n",[11,2825,2826],{},"The script gets executed, not pasted into the conversation, so it doesn't consume context. The agent simply runs it, captures the result, and moves on.",[11,2828,2829],{},"Anything mechanical and deterministic belongs in a script anyway. It's faster, cheaper, and usually more reliable than asking the model to work out the same commands from scratch every single time.",[115,2831,2833],{"id":2832},"tip-2-skills-can-load-reference-files","Tip 2: Skills Can Load Reference Files",[11,2835,2836],{},"The same idea works for knowledge.",[11,2838,2839,2840,548],{},"If a skill depends on a long reference—API documentation, a style guide, naming conventions, or some giant formatting spec—you don't have to cram it all into ",[59,2841,1435],{},[11,2843,2844],{},"Instead, drop it into a dedicated file and reference it:",[126,2846,2848],{"className":1433,"code":2847,"filename":1435,"language":40,"meta":34,"style":34},"- If the project uses React, follow [react_rules.md](react_rules.md).\n- If the project uses Vue, use [vue_rules.md](vue_rules.md) instead.\n",[59,2849,2850,2868],{"__ignoreMap":34},[134,2851,2852,2854,2857,2860,2863,2865],{"class":136,"line":137},[134,2853,1442],{"class":140},[134,2855,2856],{"class":265}," If the project uses React, follow [",[134,2858,2859],{"class":150},"react_rules.md",[134,2861,2862],{"class":265},"](",[134,2864,2859],{"class":143},[134,2866,2867],{"class":265},").\n",[134,2869,2870,2872,2875,2878,2880,2882],{"class":136,"line":35},[134,2871,1442],{"class":140},[134,2873,2874],{"class":265}," If the project uses Vue, use [",[134,2876,2877],{"class":150},"vue_rules.md",[134,2879,2862],{"class":265},[134,2881,2877],{"class":143},[134,2883,2884],{"class":265},") instead.\n",[11,2886,2887],{},"Claude only reads the file it actually needs.",[11,2889,2890,2891,2894,2895,2897],{},"This pattern is called ",[56,2892,2893],{},"progressive disclosure",". The body of ",[59,2896,1435],{}," is loaded when the skill is invoked, and any referenced files are read only if the task actually requires them.",[11,2899,2900,2901,2903],{},"Anthropic recommends keeping ",[59,2902,1435],{}," under 500 lines and pushing heavier details into supporting files. After using skills for a while, that advice makes a lot of sense.",[115,2905,2907],{"id":2906},"tip-3-skills-can-pre-approve-tools","Tip 3: Skills Can Pre-Approve Tools",[11,2909,2910,2911,2914],{},"You can add an ",[59,2912,2913],{},"allowed-tools"," line in the frontmatter to pre-approve specific tools, so a mostly mechanical skill doesn't keep stopping to ask for permission.",[11,2916,2917],{},"This is especially useful for skills that always perform the same safe operations, like running checks, formatting code, or committing.",[126,2919,2921],{"className":1433,"code":2920,"language":40,"meta":34,"style":34},"---\nname: commit\ndescription: Stage and commit the current changes with a concise message.\nallowed-tools: Bash(git add *) Bash(git commit *)\n---\n",[59,2922,2923,2927,2936,2945,2954],{"__ignoreMap":34},[134,2924,2925],{"class":136,"line":137},[134,2926,1748],{"class":265},[134,2928,2929,2931,2933],{"class":136,"line":35},[134,2930,1753],{"class":1112},[134,2932,275],{"class":265},[134,2934,2935],{"class":143},"commit\n",[134,2937,2938,2940,2942],{"class":136,"line":169},[134,2939,1763],{"class":1112},[134,2941,275],{"class":265},[134,2943,2944],{"class":143},"Stage and commit the current changes with a concise message.\n",[134,2946,2947,2949,2951],{"class":136,"line":297},[134,2948,2913],{"class":1112},[134,2950,275],{"class":265},[134,2952,2953],{"class":143},"Bash(git add *) Bash(git commit *)\n",[134,2955,2956],{"class":136,"line":310},[134,2957,1748],{"class":265},[11,2959,2960,2961,2964],{},"The opposite works too: use the ",[59,2962,2963],{},"disallowed-tools"," field to block specific tools.",[2966,2967],"hr",{},[99,2969,2971],{"id":2970},"turning-a-repetitive-workflow-into-a-skill","Turning a Repetitive Workflow Into a Skill",[11,2973,2974,2975,2977],{},"Now, we can put all these tips into action to create our full ",[59,2976,1455],{}," skill.",[11,2979,2980],{},"As I said at the beginning, the routine I got tired of looked something like this:",[64,2982,2983,2986,2989,2992,2995,2998],{},[67,2984,2985],{},"Branch off the default branch.",[67,2987,2988],{},"Stage the changes.",[67,2990,2991],{},"Read the full diff instead of trusting the commit messages.",[67,2993,2994],{},"With that information, write a descriptive title and short description.",[67,2996,2997],{},"Open the PR in GitHub.",[67,2999,3000],{},"Request a review from Copilot, GitHub's automated code review bot.",[11,3002,3003],{},"So let's turn that workflow into something the agent can execute.",[11,3005,3006,3007,3009],{},"Instead of cramming everything into one giant ",[59,3008,1435],{},", let's split it into different files:",[126,3011,3014],{"className":3012,"code":3013,"language":2598,"meta":34},[2596],"📂 github-pr/\n├── 📄 SKILL.md\n├── 📄 pull_request_description_template.md\n└── ▶️ rebase.sh\n",[59,3015,3013],{"__ignoreMap":34},[64,3017,3018,3023,3029],{},[67,3019,3020,3022],{},[59,3021,1435],{}," is the main recipe.",[67,3024,3025,3028],{},[59,3026,3027],{},"pull_request_description_template.md"," is the PR description base.",[67,3030,3031,3034],{},[59,3032,3033],{},"rebase.sh"," handles the one mechanical step that's worth scripting.",[11,3036,3037],{},"Let's review each, starting with the skill file:",[126,3039,3041],{"className":1433,"code":3040,"filename":1435,"language":40,"meta":34,"style":34},"---\nname: github-pr\ndescription: Create a GitHub PR with a concise description.\nallowed-tools: Bash(git *) Bash(gh pr create *)\n---\n\n# GitHub PR Skill\n\nOpen a pull request with a short, descriptive title and a clear description.\n\n## Usage\n\n`/github-pr \u003Ctarget-branch>`\n\n## Steps\n\n1. Find the default branch if no target was given:\n\n`git remote show origin | sed -n 's/.*HEAD branch: //p'`\n\n2. Move the current changes onto a fresh feature branch, on top of the latest target. Name the branch after the change (e.g. \"add-user-profile\", \"fix-cart-rounding\"):\n\n`${CLAUDE_SKILL_DIR}/rebase.sh \u003Ctarget> \u003Cfeature-branch>`\n\n3. Read the full diff so the description reflects what actually changed:\n\n`git diff \u003Ctarget>...HEAD`\n\n4. Draft the PR body from [pull_request_description_template.md](pull_request_description_template.md), filling it in from the diff. Keep the title concise and descriptive (\"Add user profile page\").\n\n5. Push the branch and open the PR:\n\n```sh\ngit push --set-upstream origin \"$(git branch --show-current)\"\ngh pr create --base \u003Ctarget> --title \"\u003Ctitle>\" --body-file - \u003C\u003C'EOF'\n\u003Cthe filled-in PR body>\nEOF\n```\n\n## Tone\n\nWrite plainly. No bold labels on bullets. No AI tells (\"seamlessly\", \"leverage\", \"robust\", \"ensures that\"). No hyphens or em dashes as sentence connectors.\n\n## Rules\n\n- No co-authors.\n- No screenshots.\n- Descriptive title required.\n",[59,3042,3043,3047,3056,3065,3074,3078,3082,3087,3091,3096,3100,3105,3109,3114,3118,3122,3126,3133,3137,3142,3146,3153,3157,3162,3166,3173,3177,3182,3186,3202,3206,3213,3217,3222,3250,3291,3296,3301,3305,3309,3314,3318,3323,3327,3332,3336,3343,3350],{"__ignoreMap":34},[134,3044,3045],{"class":136,"line":137},[134,3046,1748],{"class":265},[134,3048,3049,3051,3053],{"class":136,"line":35},[134,3050,1753],{"class":1112},[134,3052,275],{"class":265},[134,3054,3055],{"class":143},"github-pr\n",[134,3057,3058,3060,3062],{"class":136,"line":169},[134,3059,1763],{"class":1112},[134,3061,275],{"class":265},[134,3063,3064],{"class":143},"Create a GitHub PR with a concise description.\n",[134,3066,3067,3069,3071],{"class":136,"line":297},[134,3068,2913],{"class":1112},[134,3070,275],{"class":265},[134,3072,3073],{"class":143},"Bash(git *) Bash(gh pr create *)\n",[134,3075,3076],{"class":136,"line":310},[134,3077,1748],{"class":265},[134,3079,3080],{"class":136,"line":323},[134,3081,1160],{"emptyLinePlaceholder":43},[134,3083,3084],{"class":136,"line":332},[134,3085,3086],{"class":1517},"# GitHub PR Skill\n",[134,3088,3089],{"class":136,"line":338},[134,3090,1160],{"emptyLinePlaceholder":43},[134,3092,3093],{"class":136,"line":351},[134,3094,3095],{"class":265},"Open a pull request with a short, descriptive title and a clear description.\n",[134,3097,3098],{"class":136,"line":364},[134,3099,1160],{"emptyLinePlaceholder":43},[134,3101,3102],{"class":136,"line":377},[134,3103,3104],{"class":1517},"## Usage\n",[134,3106,3107],{"class":136,"line":390},[134,3108,1160],{"emptyLinePlaceholder":43},[134,3110,3111],{"class":136,"line":403},[134,3112,3113],{"class":1448},"`/github-pr \u003Ctarget-branch>`\n",[134,3115,3116],{"class":136,"line":414},[134,3117,1160],{"emptyLinePlaceholder":43},[134,3119,3120],{"class":136,"line":420},[134,3121,2161],{"class":1517},[134,3123,3124],{"class":136,"line":425},[134,3125,1160],{"emptyLinePlaceholder":43},[134,3127,3128,3130],{"class":136,"line":436},[134,3129,2170],{"class":140},[134,3131,3132],{"class":265}," Find the default branch if no target was given:\n",[134,3134,3135],{"class":136,"line":448},[134,3136,1160],{"emptyLinePlaceholder":43},[134,3138,3139],{"class":136,"line":460},[134,3140,3141],{"class":1448},"`git remote show origin | sed -n 's/.*HEAD branch: //p'`\n",[134,3143,3144],{"class":136,"line":472},[134,3145,1160],{"emptyLinePlaceholder":43},[134,3147,3148,3150],{"class":136,"line":484},[134,3149,2184],{"class":140},[134,3151,3152],{"class":265}," Move the current changes onto a fresh feature branch, on top of the latest target. Name the branch after the change (e.g. \"add-user-profile\", \"fix-cart-rounding\"):\n",[134,3154,3155],{"class":136,"line":493},[134,3156,1160],{"emptyLinePlaceholder":43},[134,3158,3159],{"class":136,"line":499},[134,3160,3161],{"class":1448},"`${CLAUDE_SKILL_DIR}/rebase.sh \u003Ctarget> \u003Cfeature-branch>`\n",[134,3163,3164],{"class":136,"line":505},[134,3165,1160],{"emptyLinePlaceholder":43},[134,3167,3168,3170],{"class":136,"line":1908},[134,3169,2196],{"class":140},[134,3171,3172],{"class":265}," Read the full diff so the description reflects what actually changed:\n",[134,3174,3175],{"class":136,"line":1913},[134,3176,1160],{"emptyLinePlaceholder":43},[134,3178,3179],{"class":136,"line":1919},[134,3180,3181],{"class":1448},"`git diff \u003Ctarget>...HEAD`\n",[134,3183,3184],{"class":136,"line":1924},[134,3185,1160],{"emptyLinePlaceholder":43},[134,3187,3188,3190,3193,3195,3197,3199],{"class":136,"line":1930},[134,3189,2204],{"class":140},[134,3191,3192],{"class":265}," Draft the PR body from [",[134,3194,3027],{"class":150},[134,3196,2862],{"class":265},[134,3198,3027],{"class":143},[134,3200,3201],{"class":265},"), filling it in from the diff. Keep the title concise and descriptive (\"Add user profile page\").\n",[134,3203,3204],{"class":136,"line":1935},[134,3205,1160],{"emptyLinePlaceholder":43},[134,3207,3208,3210],{"class":136,"line":1941},[134,3209,2223],{"class":140},[134,3211,3212],{"class":265}," Push the branch and open the PR:\n",[134,3214,3215],{"class":136,"line":1946},[134,3216,1160],{"emptyLinePlaceholder":43},[134,3218,3219],{"class":136,"line":1958},[134,3220,3221],{"class":265},"```sh\n",[134,3223,3224,3227,3230,3233,3236,3239,3241,3244,3247],{"class":136,"line":1963},[134,3225,3226],{"class":140},"git",[134,3228,3229],{"class":143}," push",[134,3231,3232],{"class":150}," --set-upstream",[134,3234,3235],{"class":143}," origin",[134,3237,3238],{"class":143}," \"$(",[134,3240,3226],{"class":140},[134,3242,3243],{"class":143}," branch ",[134,3245,3246],{"class":150},"--show-current",[134,3248,3249],{"class":143},")\"\n",[134,3251,3252,3254,3257,3260,3263,3265,3268,3271,3273,3276,3279,3282,3285,3288],{"class":136,"line":1969},[134,3253,521],{"class":140},[134,3255,3256],{"class":143}," pr",[134,3258,3259],{"class":143}," create",[134,3261,3262],{"class":150}," --base",[134,3264,2662],{"class":1112},[134,3266,3267],{"class":143},"targe",[134,3269,3270],{"class":265},"t",[134,3272,2668],{"class":1112},[134,3274,3275],{"class":150}," --title",[134,3277,3278],{"class":143}," \"\u003Ctitle>\"",[134,3280,3281],{"class":150}," --body-file",[134,3283,3284],{"class":143}," -",[134,3286,3287],{"class":1112}," \u003C\u003C",[134,3289,3290],{"class":265},"'EOF'\n",[134,3292,3293],{"class":136,"line":1974},[134,3294,3295],{"class":143},"\u003Cthe filled-in PR body>\n",[134,3297,3298],{"class":136,"line":1980},[134,3299,3300],{"class":265},"EOF\n",[134,3302,3303],{"class":136,"line":1985},[134,3304,1905],{"class":265},[134,3306,3307],{"class":136,"line":1991},[134,3308,1160],{"emptyLinePlaceholder":43},[134,3310,3311],{"class":136,"line":1996},[134,3312,3313],{"class":1517},"## Tone\n",[134,3315,3316],{"class":136,"line":2002},[134,3317,1160],{"emptyLinePlaceholder":43},[134,3319,3320],{"class":136,"line":2007},[134,3321,3322],{"class":265},"Write plainly. No bold labels on bullets. No AI tells (\"seamlessly\", \"leverage\", \"robust\", \"ensures that\"). No hyphens or em dashes as sentence connectors.\n",[134,3324,3325],{"class":136,"line":2013},[134,3326,1160],{"emptyLinePlaceholder":43},[134,3328,3329],{"class":136,"line":2018},[134,3330,3331],{"class":1517},"## Rules\n",[134,3333,3334],{"class":136,"line":2024},[134,3335,1160],{"emptyLinePlaceholder":43},[134,3337,3338,3340],{"class":136,"line":2029},[134,3339,1442],{"class":140},[134,3341,3342],{"class":265}," No co-authors.\n",[134,3344,3345,3347],{"class":136,"line":2035},[134,3346,1442],{"class":140},[134,3348,3349],{"class":265}," No screenshots.\n",[134,3351,3352,3354],{"class":136,"line":2040},[134,3353,1442],{"class":140},[134,3355,3356],{"class":265}," Descriptive title required.\n",[11,3358,3359,3360,3363,3364],{},"Notice how the skill itself stays small. The real work lives in the pieces around it, which are much easier to maintain on their own: the ",[56,3361,3362],{},"script"," and the ",[56,3365,3366],{},"template.",[11,3368,3369,3370],{},"Here's the ",[56,3371,3372],{},"script:",[126,3374,3376],{"className":2608,"code":3375,"filename":3033,"language":2610,"meta":34,"style":34},"#!/usr/bin/env bash\n# Move the current local changes onto a fresh feature branch,\n# on top of the latest version of the target branch.\n# Usage: rebase.sh \u003Ctarget-branch> \u003Cfeature-branch>\nset -euo pipefail\n\nTARGET=\"${1:?usage: rebase.sh \u003Ctarget-branch> \u003Cfeature-branch>}\"\nBRANCH=\"${2:?usage: rebase.sh \u003Ctarget-branch> \u003Cfeature-branch>}\"\n\ngit stash push --include-untracked --message \"github-pr wip\" || true\ngit checkout \"$TARGET\"\ngit pull --ff-only\ngit checkout -b \"$BRANCH\"\ngit stash pop || true\n",[59,3377,3378,3382,3387,3392,3397,3405,3409,3450,3487,3491,3515,3529,3539,3555],{"__ignoreMap":34},[134,3379,3380],{"class":136,"line":137},[134,3381,2617],{"class":1165},[134,3383,3384],{"class":136,"line":35},[134,3385,3386],{"class":1165},"# Move the current local changes onto a fresh feature branch,\n",[134,3388,3389],{"class":136,"line":169},[134,3390,3391],{"class":1165},"# on top of the latest version of the target branch.\n",[134,3393,3394],{"class":136,"line":297},[134,3395,3396],{"class":1165},"# Usage: rebase.sh \u003Ctarget-branch> \u003Cfeature-branch>\n",[134,3398,3399,3401,3403],{"class":136,"line":310},[134,3400,2623],{"class":2622},[134,3402,2626],{"class":150},[134,3404,2629],{"class":143},[134,3406,3407],{"class":136,"line":323},[134,3408,1160],{"emptyLinePlaceholder":43},[134,3410,3411,3414,3416,3418,3420,3422,3424,3426,3429,3431,3433,3435,3438,3441,3444,3446,3448],{"class":136,"line":332},[134,3412,3413],{"class":265},"TARGET",[134,3415,1113],{"class":1112},[134,3417,2639],{"class":143},[134,3419,2643],{"class":2642},[134,3421,2646],{"class":1112},[134,3423,554],{"class":265},[134,3425,2651],{"class":1112},[134,3427,3428],{"class":265}," rebase",[134,3430,548],{"class":143},[134,3432,2659],{"class":265},[134,3434,2662],{"class":143},[134,3436,3437],{"class":265},"target-branch",[134,3439,3440],{"class":143},"> \u003C",[134,3442,3443],{"class":265},"feature-branch",[134,3445,2668],{"class":143},[134,3447,2671],{"class":2642},[134,3449,2674],{"class":143},[134,3451,3452,3455,3457,3459,3461,3463,3465,3467,3469,3471,3473,3475,3477,3479,3481,3483,3485],{"class":136,"line":338},[134,3453,3454],{"class":265},"BRANCH",[134,3456,1113],{"class":1112},[134,3458,2639],{"class":143},[134,3460,2686],{"class":2642},[134,3462,2646],{"class":1112},[134,3464,554],{"class":265},[134,3466,2651],{"class":1112},[134,3468,3428],{"class":265},[134,3470,548],{"class":143},[134,3472,2659],{"class":265},[134,3474,2662],{"class":143},[134,3476,3437],{"class":265},[134,3478,3440],{"class":143},[134,3480,3443],{"class":265},[134,3482,2668],{"class":143},[134,3484,2671],{"class":2642},[134,3486,2674],{"class":143},[134,3488,3489],{"class":136,"line":351},[134,3490,1160],{"emptyLinePlaceholder":43},[134,3492,3493,3495,3498,3500,3503,3506,3509,3512],{"class":136,"line":364},[134,3494,3226],{"class":140},[134,3496,3497],{"class":143}," stash",[134,3499,3229],{"class":143},[134,3501,3502],{"class":150}," --include-untracked",[134,3504,3505],{"class":150}," --message",[134,3507,3508],{"class":143}," \"github-pr wip\"",[134,3510,3511],{"class":1112}," ||",[134,3513,3514],{"class":2622}," true\n",[134,3516,3517,3519,3522,3524,3527],{"class":136,"line":377},[134,3518,3226],{"class":140},[134,3520,3521],{"class":143}," checkout",[134,3523,2720],{"class":143},[134,3525,3526],{"class":265},"$TARGET",[134,3528,2674],{"class":143},[134,3530,3531,3533,3536],{"class":136,"line":390},[134,3532,3226],{"class":140},[134,3534,3535],{"class":143}," pull",[134,3537,3538],{"class":150}," --ff-only\n",[134,3540,3541,3543,3545,3548,3550,3553],{"class":136,"line":403},[134,3542,3226],{"class":140},[134,3544,3521],{"class":143},[134,3546,3547],{"class":150}," -b",[134,3549,2720],{"class":143},[134,3551,3552],{"class":265},"$BRANCH",[134,3554,2674],{"class":143},[134,3556,3557,3559,3561,3564,3566],{"class":136,"line":414},[134,3558,3226],{"class":140},[134,3560,3497],{"class":143},[134,3562,3563],{"class":143}," pop",[134,3565,3511],{"class":1112},[134,3567,3514],{"class":2622},[11,3569,3570,3571,3574],{},"And here's the ",[56,3572,3573],{},"template"," all PR descriptions must follow:",[126,3576,3578],{"className":1433,"code":3577,"filename":3027,"language":40,"meta":34,"style":34},"## What\n\n[One sentence: what this PR does.]\n\n## Changes\n\n- [One bullet per meaningful behavior change]\n\n## How to test\n\n1. [Step by step, so a reviewer can verify it]\n",[59,3579,3580,3585,3589,3594,3598,3603,3607,3614,3618,3623,3627],{"__ignoreMap":34},[134,3581,3582],{"class":136,"line":137},[134,3583,3584],{"class":1517},"## What\n",[134,3586,3587],{"class":136,"line":35},[134,3588,1160],{"emptyLinePlaceholder":43},[134,3590,3591],{"class":136,"line":169},[134,3592,3593],{"class":265},"[One sentence: what this PR does.]\n",[134,3595,3596],{"class":136,"line":297},[134,3597,1160],{"emptyLinePlaceholder":43},[134,3599,3600],{"class":136,"line":310},[134,3601,3602],{"class":1517},"## Changes\n",[134,3604,3605],{"class":136,"line":323},[134,3606,1160],{"emptyLinePlaceholder":43},[134,3608,3609,3611],{"class":136,"line":332},[134,3610,1442],{"class":140},[134,3612,3613],{"class":265}," [One bullet per meaningful behavior change]\n",[134,3615,3616],{"class":136,"line":338},[134,3617,1160],{"emptyLinePlaceholder":43},[134,3619,3620],{"class":136,"line":351},[134,3621,3622],{"class":1517},"## How to test\n",[134,3624,3625],{"class":136,"line":364},[134,3626,1160],{"emptyLinePlaceholder":43},[134,3628,3629,3631],{"class":136,"line":377},[134,3630,2170],{"class":140},[134,3632,3633],{"class":265}," [Step by step, so a reviewer can verify it]\n",[11,3635,3636,3637,3639],{},"Now I just type ",[59,3638,2298],{}," once my code is ready for review. And boom! The PR gets created.",[11,3641,3642],{},"The skill rebases the work, reads the diff, fills in the template, and opens the PR using the description format I like. The skill knows how I like my branches named, how I want PRs written, and what I don't want in the final output. All those little preferences I used to retype, now baked into something the agent just follows.",[2966,3644],{},[99,3646,1345],{"id":1344},[11,3648,3649],{},"Skills can really make your day (and your clanker's!) way easier. I've been trying to lean into skills more and more as I find parts of my workflow that can be automated.",[11,3651,3652],{},"The more I use them, the less they feel like time-saving shortcuts and the more they feel like teaching the agent how I work.",[11,3654,3655,3656,3661],{},"To learn more about skills, visit the ",[21,3657,3660],{"href":3658,"rel":3659},"https://agentskills.io",[25],"Agent Skills"," website.",[11,3663,1419,3664,3667],{},[21,3665,3666],{"href":2352},"the next article",", we'll look at a more advanced way of working with skills: how to stack and chain them together to take our automation to the next level.",[11,3669,3670],{},"Until next time!",[1356,3672,3673],{},"html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}html pre.shiki code .snpHw, html code.shiki .snpHw{--shiki-default:#88846F}html pre.shiki code .sYf5A, html code.shiki .sYf5A{--shiki-default:#66D9EF}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html pre.shiki code .sW0Xf, html code.shiki .sW0Xf{--shiki-default:#FD971F;--shiki-default-font-style:italic}html pre.shiki code .szGx7, html code.shiki .szGx7{--shiki-default:#FD971F}html pre.shiki code .ssRA_, html code.shiki .ssRA_{--shiki-default:#A6E22E;--shiki-default-font-weight:bold}",{"title":34,"searchDepth":35,"depth":35,"links":3675},[3676,3677,3682,3683],{"id":2414,"depth":35,"text":2415},{"id":2539,"depth":35,"text":2540,"children":3678},[3679,3680,3681],{"id":2550,"depth":169,"text":2551},{"id":2832,"depth":169,"text":2833},{"id":2906,"depth":169,"text":2907},{"id":2970,"depth":35,"text":2971},{"id":1344,"depth":35,"text":1345},"2026-07-08","If you keep giving your agent the same instructions over and over, write them down once as a skill and let it do the repetitive bits.","/img/blog/skills-i-know-kung-fu.png",{},{"title":2358,"description":3685},"blog/skills-i-know-kung-fu","ofnT_V552bv231JR8JZn6LX_1oEXMpEkTfGj3syw58U",{"id":3692,"title":3693,"body":3694,"category":4393,"date":4394,"description":4395,"draft":39,"extension":40,"image":4396,"meta":4397,"navigation":43,"path":4398,"seo":4399,"stem":4400,"tighten":39,"__hash__":4401},"blog/blog/run-multiple-node-js-apps-on-one-server-with-pm2.md","Run Multiple Node.js Apps on One Server with PM2",{"type":8,"value":3695,"toc":4385},[3696,3699,3706,3713,3720,3727,3738,3741,3745,3751,3770,3781,3796,3802,3805,3817,3820,3826,3829,3841,3844,3850,3854,3857,3872,3883,3903,3910,3914,3917,3934,3937,4141,4144,4158,4161,4193,4196,4200,4207,4224,4231,4301,4304,4307,4311,4314,4319,4322,4325,4337,4340,4343,4355,4358,4361,4368,4370,4373,4376,4379,4382],[11,3697,3698],{},"I love static sites and serverless functions. With those two, you can build just about anything.",[11,3700,3701,3702,3705],{},"That said, sometimes you need the full power of ",[56,3703,3704],{},"a server running on your own infrastructure."," Maybe you need to run long-lived processes, heavy background jobs, or just host your own apps and services without the constraints of serverless functions.",[11,3707,3708,3709,3712],{},"The challenge starts once you have ",[56,3710,3711],{},"multiple applications"," running on the same server, each with its own Node process. How do you keep them running 24/7? How do you make sure they restart if they crash? How do you manage all of them without turning your server into a mess?",[11,3714,3715,3716,3719],{},"That's where ",[56,3717,3718],{},"PM2"," comes in.",[11,3721,3722,3723,3726],{},"PM2 is a ",[56,3724,3725],{},"process manager for Node.js"," that makes it easy to run and manage multiple applications. From simple scripts to full-stack Nuxt apps, PM2 helps you orchestrate them and:",[64,3728,3729,3732,3735],{},[67,3730,3731],{},"Monitor and control uptime and memory usage,",[67,3733,3734],{},"Automatically restart them if they crash,",[67,3736,3737],{},"Check out errors and logs",[11,3739,3740],{},"... and more. Let's check it out.",[99,3742,3744],{"id":3743},"installing-pm2","Installing PM2",[11,3746,3747,3748],{},"Installing PM2 is as simple as installing any other global ",[56,3749,3750],{},"npm package:",[126,3752,3754],{"className":128,"code":3753,"language":130,"meta":34,"style":34},"npm install -g pm2\n",[59,3755,3756],{"__ignoreMap":34},[134,3757,3758,3761,3764,3767],{"class":136,"line":137},[134,3759,3760],{"class":140},"npm",[134,3762,3763],{"class":143}," install",[134,3765,3766],{"class":150}," -g",[134,3768,3769],{"class":143}," pm2\n",[11,3771,3772,3773,3776,3777,3780],{},"Once installed, run it with ",[59,3774,3775],{},"pm2",". Let's start with the simplest possible example. Imagine you have a file called ",[59,3778,3779],{},"index.js",", you can kickstart it with:",[126,3782,3784],{"className":128,"code":3783,"language":130,"meta":34,"style":34},"pm2 start index.js\n",[59,3785,3786],{"__ignoreMap":34},[134,3787,3788,3790,3793],{"class":136,"line":137},[134,3789,3775],{"class":140},[134,3791,3792],{"class":143}," start",[134,3794,3795],{"class":143}," index.js\n",[11,3797,3798,3801],{},[56,3799,3800],{},"That's it."," PM2 will start the process, keep it running, and automatically restart it if it crashes.",[11,3803,3804],{},"You can check all running processes at any time with:",[126,3806,3808],{"className":128,"code":3807,"language":130,"meta":34,"style":34},"pm2 list\n",[59,3809,3810],{"__ignoreMap":34},[134,3811,3812,3814],{"class":136,"line":137},[134,3813,3775],{"class":140},[134,3815,3816],{"class":143}," list\n",[11,3818,3819],{},"Here's what you'll get:",[126,3821,3824],{"className":3822,"code":3823,"language":2598},[2596],"┌────┬─────────┬─────────┬─────────┬──────────┬──────────┐\n│ id │ name    │ status  │ restart │ uptime   │ memory   │\n├────┼─────────┼─────────┼─────────┼──────────┼──────────┤\n│ 0  │ index   │ online  │ 0       │ 2m       │ 41.8mb   │\n└────┴─────────┴─────────┴─────────┴──────────┴──────────┘\n",[59,3825,3823],{"__ignoreMap":34},[11,3827,3828],{},"And if you want to see the logs:",[126,3830,3832],{"className":128,"code":3831,"language":130,"meta":34,"style":34},"pm2 logs\n",[59,3833,3834],{"__ignoreMap":34},[134,3835,3836,3838],{"class":136,"line":137},[134,3837,3775],{"class":140},[134,3839,3840],{"class":143}," logs\n",[11,3842,3843],{},"... will show you something like:",[126,3845,3848],{"className":3846,"code":3847,"language":2598},[2596],"[TAILING] Tailing last 15 lines for [index] process (change the value with --lines option)\n\n0|index | Server listening on http://localhost:3000\n0|index | GET /         200  9ms\n0|index | GET /health   200  1ms\n",[59,3849,3847],{"__ignoreMap":34},[99,3851,3853],{"id":3852},"running-a-nuxt-application","Running a Nuxt Application",[11,3855,3856],{},"Running a Nuxt application is just as straightforward. First, build your project:",[126,3858,3860],{"className":128,"code":3859,"language":130,"meta":34,"style":34},"npm run build\n",[59,3861,3862],{"__ignoreMap":34},[134,3863,3864,3866,3869],{"class":136,"line":137},[134,3865,3760],{"class":140},[134,3867,3868],{"class":143}," run",[134,3870,3871],{"class":143}," build\n",[11,3873,3874,3875,3878,3879,3882],{},"The build produces a ",[59,3876,3877],{},".output"," folder with a self-contained Node server inside it, at ",[59,3880,3881],{},".output/server/index.mjs",". Instead of running that file directly, hand it to PM2:",[126,3884,3886],{"className":128,"code":3885,"language":130,"meta":34,"style":34},"pm2 start .output/server/index.mjs --name my-nuxt-app\n",[59,3887,3888],{"__ignoreMap":34},[134,3889,3890,3892,3894,3897,3900],{"class":136,"line":137},[134,3891,3775],{"class":140},[134,3893,3792],{"class":143},[134,3895,3896],{"class":143}," .output/server/index.mjs",[134,3898,3899],{"class":150}," --name",[134,3901,3902],{"class":143}," my-nuxt-app\n",[11,3904,3905,3906,3909],{},"PM2 will launch the ",[110,3907,3908],{},"exact same"," production server you'd get by running that file yourself, but now it will monitor the process, restart it if it crashes, and let you manage it alongside any other applications running on the server.",[99,3911,3913],{"id":3912},"the-ecosystem-file","The Ecosystem File",[11,3915,3916],{},"Once you start running more than a handful of applications, managing everything through individual PM2 commands can become a bit cumbersome. Starting each process one by one, remembering which port it uses, and keeping track of its configuration doesn't scale very well.",[11,3918,3919,3920,3923,3924,3926,3927,3929,3930,3933],{},"A much better approach is to define everything in a single ",[56,3921,3922],{},"ecosystem file",". It's a plain JavaScript config where you describe every application you want PM2 to manage: its ",[59,3925,1753],{},", the ",[59,3928,3362],{}," to run, its working directory (",[59,3931,3932],{},"cwd","), environment variables, ports, log files, restart policy, and any other setting you need.",[11,3935,3936],{},"For example:",[126,3938,3943],{"className":3939,"code":3940,"filename":3941,"language":3942,"meta":34,"style":34},"language-js shiki shiki-themes monokai","module.exports = {\n  apps: [\n    {\n      name: \"web-app\",\n      cwd: \"/var/www/web-app\",\n      script: \"index.js\",\n      env: {\n        PORT: 3000,\n      },\n    },\n    {\n      name: \"my-nuxt-app\",\n      cwd: \"/var/www/my-nuxt-app\",\n      script: \".output/server/index.mjs\",\n      env: {\n        PORT: 3001,\n      },\n    },\n    {\n      name: \"email-worker\",\n      cwd: \"/var/www/email-worker\",\n      script: \"worker.js\",\n      error_file: \"./logs/error.log\",\n      out_file: \"./logs/output.log\",\n    },\n  ],\n};\n","ecosystem.config.js","js",[59,3944,3945,3961,3966,3970,3980,3990,4000,4005,4015,4020,4024,4028,4037,4046,4055,4059,4068,4072,4076,4080,4089,4098,4107,4117,4127,4131,4136],{"__ignoreMap":34},[134,3946,3947,3950,3952,3955,3958],{"class":136,"line":137},[134,3948,3949],{"class":271},"module",[134,3951,548],{"class":265},[134,3953,3954],{"class":271},"exports",[134,3956,3957],{"class":1112}," =",[134,3959,3960],{"class":265}," {\n",[134,3962,3963],{"class":136,"line":35},[134,3964,3965],{"class":265},"  apps: [\n",[134,3967,3968],{"class":136,"line":169},[134,3969,335],{"class":265},[134,3971,3972,3975,3978],{"class":136,"line":297},[134,3973,3974],{"class":265},"      name: ",[134,3976,3977],{"class":143},"\"web-app\"",[134,3979,281],{"class":265},[134,3981,3982,3985,3988],{"class":136,"line":310},[134,3983,3984],{"class":265},"      cwd: ",[134,3986,3987],{"class":143},"\"/var/www/web-app\"",[134,3989,281],{"class":265},[134,3991,3992,3995,3998],{"class":136,"line":323},[134,3993,3994],{"class":265},"      script: ",[134,3996,3997],{"class":143},"\"index.js\"",[134,3999,281],{"class":265},[134,4001,4002],{"class":136,"line":332},[134,4003,4004],{"class":265},"      env: {\n",[134,4006,4007,4010,4013],{"class":136,"line":338},[134,4008,4009],{"class":265},"        PORT: ",[134,4011,4012],{"class":150},"3000",[134,4014,281],{"class":265},[134,4016,4017],{"class":136,"line":351},[134,4018,4019],{"class":265},"      },\n",[134,4021,4022],{"class":136,"line":364},[134,4023,417],{"class":265},[134,4025,4026],{"class":136,"line":377},[134,4027,335],{"class":265},[134,4029,4030,4032,4035],{"class":136,"line":390},[134,4031,3974],{"class":265},[134,4033,4034],{"class":143},"\"my-nuxt-app\"",[134,4036,281],{"class":265},[134,4038,4039,4041,4044],{"class":136,"line":403},[134,4040,3984],{"class":265},[134,4042,4043],{"class":143},"\"/var/www/my-nuxt-app\"",[134,4045,281],{"class":265},[134,4047,4048,4050,4053],{"class":136,"line":414},[134,4049,3994],{"class":265},[134,4051,4052],{"class":143},"\".output/server/index.mjs\"",[134,4054,281],{"class":265},[134,4056,4057],{"class":136,"line":420},[134,4058,4004],{"class":265},[134,4060,4061,4063,4066],{"class":136,"line":425},[134,4062,4009],{"class":265},[134,4064,4065],{"class":150},"3001",[134,4067,281],{"class":265},[134,4069,4070],{"class":136,"line":436},[134,4071,4019],{"class":265},[134,4073,4074],{"class":136,"line":448},[134,4075,417],{"class":265},[134,4077,4078],{"class":136,"line":460},[134,4079,335],{"class":265},[134,4081,4082,4084,4087],{"class":136,"line":472},[134,4083,3974],{"class":265},[134,4085,4086],{"class":143},"\"email-worker\"",[134,4088,281],{"class":265},[134,4090,4091,4093,4096],{"class":136,"line":484},[134,4092,3984],{"class":265},[134,4094,4095],{"class":143},"\"/var/www/email-worker\"",[134,4097,281],{"class":265},[134,4099,4100,4102,4105],{"class":136,"line":493},[134,4101,3994],{"class":265},[134,4103,4104],{"class":143},"\"worker.js\"",[134,4106,281],{"class":265},[134,4108,4109,4112,4115],{"class":136,"line":499},[134,4110,4111],{"class":265},"      error_file: ",[134,4113,4114],{"class":143},"\"./logs/error.log\"",[134,4116,281],{"class":265},[134,4118,4119,4122,4125],{"class":136,"line":505},[134,4120,4121],{"class":265},"      out_file: ",[134,4123,4124],{"class":143},"\"./logs/output.log\"",[134,4126,281],{"class":265},[134,4128,4129],{"class":136,"line":1908},[134,4130,417],{"class":265},[134,4132,4133],{"class":136,"line":1913},[134,4134,4135],{"class":265},"  ],\n",[134,4137,4138],{"class":136,"line":1919},[134,4139,4140],{"class":265},"};\n",[11,4142,4143],{},"Once you have your ecosystem file, starting every application is just a single command:",[126,4145,4147],{"className":128,"code":4146,"language":130,"meta":34,"style":34},"pm2 start ecosystem.config.js\n",[59,4148,4149],{"__ignoreMap":34},[134,4150,4151,4153,4155],{"class":136,"line":137},[134,4152,3775],{"class":140},[134,4154,3792],{"class":143},[134,4156,4157],{"class":143}," ecosystem.config.js\n",[11,4159,4160],{},"You can also restart, stop, or delete the entire group just as easily:",[126,4162,4164],{"className":128,"code":4163,"language":130,"meta":34,"style":34},"pm2 restart ecosystem.config.js\npm2 stop ecosystem.config.js\npm2 delete ecosystem.config.js\n",[59,4165,4166,4175,4184],{"__ignoreMap":34},[134,4167,4168,4170,4173],{"class":136,"line":137},[134,4169,3775],{"class":140},[134,4171,4172],{"class":143}," restart",[134,4174,4157],{"class":143},[134,4176,4177,4179,4182],{"class":136,"line":35},[134,4178,3775],{"class":140},[134,4180,4181],{"class":143}," stop",[134,4183,4157],{"class":143},[134,4185,4186,4188,4191],{"class":136,"line":169},[134,4187,3775],{"class":140},[134,4189,4190],{"class":143}," delete",[134,4192,4157],{"class":143},[11,4194,4195],{},"With that in place, your entire stack can be managed from a single configuration file instead of dozens of individual commands. It also makes your server configuration reproducible, easy to version control, and much easier to maintain as your infrastructure grows.",[99,4197,4199],{"id":4198},"reverse-proxies","Reverse Proxies",[11,4201,4202,4203,522,4205,548],{},"If you have multiple Nuxt apps, each one runs on a different port, like ",[59,4204,4012],{},[59,4206,4065],{},[11,4208,4209,4210,4213,4214,4216,4217,4220,4221,4223],{},"So how does one server know that ",[59,4211,4212],{},"marketing.com"," should go to the app on port ",[59,4215,4012],{},", while ",[59,4218,4219],{},"dashboard.com"," goes to the one on port ",[59,4222,4065],{},"?",[11,4225,4226,4227,4230],{},"That's the job of a ",[56,4228,4229],{},"reverse proxy",". A web server like Nginx sits in front of your apps, reads the domain of each incoming request, and forwards it to the right local port. A minimal configuration for a single site looks like this:",[126,4232,4237],{"className":4233,"code":4234,"filename":4235,"language":4236,"meta":34,"style":34},"language-nginx shiki shiki-themes monokai","server {\n  listen 80;\n  server_name marketing.com;\n\n  location / {\n    proxy_pass http://localhost:3000;\n    proxy_set_header Host $host;\n  }\n}\n","/etc/nginx/sites-available/marketing","nginx",[59,4238,4239,4246,4257,4265,4269,4277,4285,4293,4297],{"__ignoreMap":34},[134,4240,4241,4244],{"class":136,"line":137},[134,4242,4243],{"class":271},"server",[134,4245,3960],{"class":265},[134,4247,4248,4251,4254],{"class":136,"line":35},[134,4249,4250],{"class":1112},"  listen ",[134,4252,4253],{"class":150},"80",[134,4255,4256],{"class":265},";\n",[134,4258,4259,4262],{"class":136,"line":169},[134,4260,4261],{"class":1112},"  server_name ",[134,4263,4264],{"class":265},"marketing.com;\n",[134,4266,4267],{"class":136,"line":297},[134,4268,1160],{"emptyLinePlaceholder":43},[134,4270,4271,4274],{"class":136,"line":310},[134,4272,4273],{"class":271},"  location",[134,4275,4276],{"class":265}," / {\n",[134,4278,4279,4282],{"class":136,"line":323},[134,4280,4281],{"class":1112},"    proxy_pass ",[134,4283,4284],{"class":265},"http://localhost:3000;\n",[134,4286,4287,4290],{"class":136,"line":332},[134,4288,4289],{"class":1112},"    proxy_set_header ",[134,4291,4292],{"class":265},"Host $host;\n",[134,4294,4295],{"class":136,"line":338},[134,4296,1720],{"class":265},[134,4298,4299],{"class":136,"line":351},[134,4300,508],{"class":265},[11,4302,4303],{},"You write one of these blocks per domain, each pointing at the port of its app, and Nginx takes care of the routing from there.",[11,4305,4306],{},"Configuring Nginx from scratch (and adding HTTPS with something like Let's Encrypt) is a topic of its own, but this is the piece that connects your PM2 processes to the outside world.",[99,4308,4310],{"id":4309},"surviving-a-server-reboot","Surviving a Server Reboot",[11,4312,4313],{},"At this point, your applications are running under PM2, but there's still one important problem left to solve. You might be wondering:",[1296,4315,4316],{},[11,4317,4318],{},"What happens if the server reboots?",[11,4320,4321],{},"By default, PM2 won't automatically restore your applications after a system restart. Fortunately, enabling that behavior only takes a couple of commands.",[11,4323,4324],{},"First, save the list of applications that are currently running:",[126,4326,4328],{"className":128,"code":4327,"language":130,"meta":34,"style":34},"pm2 save\n",[59,4329,4330],{"__ignoreMap":34},[134,4331,4332,4334],{"class":136,"line":137},[134,4333,3775],{"class":140},[134,4335,4336],{"class":143}," save\n",[11,4338,4339],{},"This creates a snapshot of your current processes, including everything defined in your ecosystem file.",[11,4341,4342],{},"Next, tell your operating system to start PM2 automatically during boot:",[126,4344,4346],{"className":128,"code":4345,"language":130,"meta":34,"style":34},"pm2 startup\n",[59,4347,4348],{"__ignoreMap":34},[134,4349,4350,4352],{"class":136,"line":137},[134,4351,3775],{"class":140},[134,4353,4354],{"class":143}," startup\n",[11,4356,4357],{},"PM2 will detect your operating system and print a command to the terminal. Simply copy and run that command once. It registers PM2 as a system service so it starts automatically whenever the server boots.",[11,4359,4360],{},"From that point on, every time your VPS restarts, PM2 will start automatically and restore all the processes you previously saved.",[11,4362,4363,4364,4367],{},"It's a small step, and one of the easiest to forget. It's also one of the ",[110,4365,4366],{},"most important",", since it's what lets your applications come back on their own after a reboot, with no manual intervention.",[99,4369,1345],{"id":1344},[11,4371,4372],{},"PM2 is one of those tools you can learn in an afternoon and keep using for years.",[11,4374,4375],{},"If you're hosting your own Node.js applications, it solves a surprising number of problems: it keeps your processes alive, manages multiple services at once, organizes your infrastructure into a single ecosystem file, and brings everything back online after a server reboot.",[11,4377,4378],{},"The best part is that, once it's set up, it doesn't demand much attention. It quietly does its job while you focus on building your applications instead of worrying about whether they'll still be running tomorrow.",[11,4380,4381],{},"If you give it a try on your own server, I'd like to hear how it goes!",[1356,4383,4384],{},"html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}",{"title":34,"searchDepth":35,"depth":35,"links":4386},[4387,4388,4389,4390,4391,4392],{"id":3743,"depth":35,"text":3744},{"id":3852,"depth":35,"text":3853},{"id":3912,"depth":35,"text":3913},{"id":4198,"depth":35,"text":4199},{"id":4309,"depth":35,"text":4310},{"id":1344,"depth":35,"text":1345},"DevOps","2026-03-11","Learn how to use PM2 to run, monitor, and manage multiple Node.js applications on your own server.","/img/blog/run-multiple-node-js-apps-on-one-server-with-pm2.png",{},"/blog/run-multiple-node-js-apps-on-one-server-with-pm2",{"title":3693,"description":4395},"blog/run-multiple-node-js-apps-on-one-server-with-pm2","X7HnEUTWfdh1oKnG3Ioqh-XwHS9at1WA4DJbFIyMjcs",[4403,5784],{"id":4404,"title":4405,"body":4406,"category":4393,"date":5776,"description":5777,"draft":39,"extension":40,"image":5778,"meta":5779,"navigation":43,"path":5780,"seo":5781,"stem":5782,"tighten":39,"__hash__":5783},"blog/blog/automatically-deploy-your-nuxt-static-site-to-digitalocean-with-github-actions.md","Automatically Deploy Your Nuxt Static Site to DigitalOcean with GitHub Actions",{"type":8,"value":4407,"toc":5759},[4408,4415,4418,4427,4441,4447,4464,4468,4479,4486,4493,4841,4845,4848,4852,4893,4899,4903,4928,4946,4950,4982,4993,4997,5038,5044,5054,5058,5082,5092,5098,5109,5118,5124,5137,5141,5174,5192,5196,5199,5232,5246,5250,5253,5278,5293,5296,5306,5309,5314,5336,5350,5354,5367,5370,5386,5390,5418,5422,5467,5473,5515,5518,5555,5559,5565,5598,5612,5679,5682,5686,5705,5709,5712,5734,5738,5744,5747,5756],[11,4409,4410,4411,4414],{},"Auto-deploy is one of the great features of Netlify and Vercel. Whenever you push to ",[59,4412,4413],{},"main","... done! Your site builds automatically. I love that, but I prefer to manage my own server.",[11,4416,4417],{},"I mean, those platforms are great, but I don't want to worry about surpassing the free tier usage and receiving a big bill just for hosting my static site. If I suddenly have tons of traffic, it's most likely a random DDoS instead of one of my posts going viral (prove me wrong by sharing the post if you like it!).",[11,4419,4420,4421,4424,4425,548],{},"So, how can we setup a similar auto-deploy on a virtual private server? Well, we can use ",[56,4422,4423],{},"GitHub Actions:"," a powerful way to automate development workflows. Today we'll create a workflow that will build our site and copy it into the production server, on any push to ",[59,4426,4413],{},[11,4428,2530,4429,4432,4433,4436,4437,4440],{},[110,4430,4431],{},"best"," part? The web server won't have to run ",[59,4434,4435],{},"npm install"," nor host the ",[59,4438,4439],{},"node_modules"," folder. It will receive the static site, already generated. So, just a few MB of HTML, JavaScript, and CSS.",[11,4442,2530,4443,4446],{},[110,4444,4445],{},"best best"," part? We can run up to 1,000 minutes of GitHub Actions per month for free, which is way, way more than we need to deploy a static site that changes, at most, once per day.",[11,4448,4449,4450,4453,4454,621,4457,621,4460,4463],{},"In this guide, I'll walk you through exactly how to do it, step by step. We'll use ",[56,4451,4452],{},"Nuxt",", but you can adapt the code for ",[56,4455,4456],{},"Vue",[56,4458,4459],{},"Astro",[56,4461,4462],{},"React",", or any other framework that creates a static site or SPA.",[99,4465,4467],{"id":4466},"the-workflow-file","The Workflow File",[11,4469,4470,4471,4474,4475,4478],{},"Setting up a GitHub action is quite straightforward. We need to create a ",[59,4472,4473],{},".github"," directory in the root of our repository. Inside, we'll have a ",[59,4476,4477],{},"workflows"," directory, where we'll store YAML files with the workflow configuration.",[11,4480,4481,4482,4485],{},"For our goal, we'll only need one workflow. So, in ",[59,4483,4484],{},".github/workflows/deploy.yaml",", you can store the following content. We'll explore what each of these sections means throughout this article.",[11,4487,4488,4489,4492],{},"For now, just know that in our case, the static website is located in the ",[59,4490,4491],{},"/var/www/project/website"," folder on the server. You can adjust this workflow to match the correct directory for your server.",[126,4494,4498],{"className":4495,"code":4496,"language":4497,"meta":34,"style":34},"language-yaml shiki shiki-themes monokai","name: Deploy to DigitalOcean\n\non:\n    push:\n        branches:\n            - main\n\njobs:\n    deploy:\n        runs-on: ubuntu-latest\n        steps:\n            - name: Checkout Code\n              uses: actions/checkout@v4\n\n            - name: Setup Node.js\n              uses: actions/setup-node@v4\n              with:\n                  node-version: '22'\n\n            - name: Create .env File\n              run: echo \"${{ secrets.ENV }}\" > .env\n\n            - name: Install Dependencies and Build\n              run: |\n                  npm install\n                  npm run generate\n\n            - name: Compress Build Folder\n              run: |\n                  cd ./.output\n                  tar -czvf build.tar.gz ./public\n\n            - name: Copy to Server\n              run: |\n                  cd ./.output\n                  echo \"${{ secrets.SSH_PRIVATE_KEY }}\" > id_rsa\n                  chmod 600 id_rsa\n                  scp -o StrictHostKeyChecking=no -i id_rsa build.tar.gz ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/var/www/project/build.tar.gz\n\n            - name: Extract Build Folder On Server\n              run: |\n                  cd ./.output\n                  ssh -i id_rsa -o StrictHostKeyChecking=no ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} \u003C\u003C 'EOF'\n                  cd /var/www/project\n                  tar -xzvf build.tar.gz\n                  rm build.tar.gz\n                  mv website website_old\n                  mv public website\n                  rm -rf website_old\n                  EOF\n","yaml",[59,4499,4500,4509,4513,4521,4528,4535,4543,4547,4554,4561,4571,4578,4589,4599,4603,4614,4623,4630,4640,4644,4655,4665,4669,4680,4689,4694,4699,4703,4714,4722,4727,4732,4736,4747,4755,4759,4764,4769,4774,4778,4789,4797,4801,4806,4811,4816,4821,4826,4831,4836],{"__ignoreMap":34},[134,4501,4502,4504,4506],{"class":136,"line":137},[134,4503,1753],{"class":1112},[134,4505,275],{"class":265},[134,4507,4508],{"class":143},"Deploy to DigitalOcean\n",[134,4510,4511],{"class":136,"line":35},[134,4512,1160],{"emptyLinePlaceholder":43},[134,4514,4515,4518],{"class":136,"line":169},[134,4516,4517],{"class":150},"on",[134,4519,4520],{"class":265},":\n",[134,4522,4523,4526],{"class":136,"line":297},[134,4524,4525],{"class":1112},"    push",[134,4527,4520],{"class":265},[134,4529,4530,4533],{"class":136,"line":310},[134,4531,4532],{"class":1112},"        branches",[134,4534,4520],{"class":265},[134,4536,4537,4540],{"class":136,"line":323},[134,4538,4539],{"class":265},"            - ",[134,4541,4542],{"class":143},"main\n",[134,4544,4545],{"class":136,"line":332},[134,4546,1160],{"emptyLinePlaceholder":43},[134,4548,4549,4552],{"class":136,"line":338},[134,4550,4551],{"class":1112},"jobs",[134,4553,4520],{"class":265},[134,4555,4556,4559],{"class":136,"line":351},[134,4557,4558],{"class":1112},"    deploy",[134,4560,4520],{"class":265},[134,4562,4563,4566,4568],{"class":136,"line":364},[134,4564,4565],{"class":1112},"        runs-on",[134,4567,275],{"class":265},[134,4569,4570],{"class":143},"ubuntu-latest\n",[134,4572,4573,4576],{"class":136,"line":377},[134,4574,4575],{"class":1112},"        steps",[134,4577,4520],{"class":265},[134,4579,4580,4582,4584,4586],{"class":136,"line":390},[134,4581,4539],{"class":265},[134,4583,1753],{"class":1112},[134,4585,275],{"class":265},[134,4587,4588],{"class":143},"Checkout Code\n",[134,4590,4591,4594,4596],{"class":136,"line":403},[134,4592,4593],{"class":1112},"              uses",[134,4595,275],{"class":265},[134,4597,4598],{"class":143},"actions/checkout@v4\n",[134,4600,4601],{"class":136,"line":414},[134,4602,1160],{"emptyLinePlaceholder":43},[134,4604,4605,4607,4609,4611],{"class":136,"line":420},[134,4606,4539],{"class":265},[134,4608,1753],{"class":1112},[134,4610,275],{"class":265},[134,4612,4613],{"class":143},"Setup Node.js\n",[134,4615,4616,4618,4620],{"class":136,"line":425},[134,4617,4593],{"class":1112},[134,4619,275],{"class":265},[134,4621,4622],{"class":143},"actions/setup-node@v4\n",[134,4624,4625,4628],{"class":136,"line":436},[134,4626,4627],{"class":1112},"              with",[134,4629,4520],{"class":265},[134,4631,4632,4635,4637],{"class":136,"line":448},[134,4633,4634],{"class":1112},"                  node-version",[134,4636,275],{"class":265},[134,4638,4639],{"class":143},"'22'\n",[134,4641,4642],{"class":136,"line":460},[134,4643,1160],{"emptyLinePlaceholder":43},[134,4645,4646,4648,4650,4652],{"class":136,"line":472},[134,4647,4539],{"class":265},[134,4649,1753],{"class":1112},[134,4651,275],{"class":265},[134,4653,4654],{"class":143},"Create .env File\n",[134,4656,4657,4660,4662],{"class":136,"line":484},[134,4658,4659],{"class":1112},"              run",[134,4661,275],{"class":265},[134,4663,4664],{"class":143},"echo \"${{ secrets.ENV }}\" > .env\n",[134,4666,4667],{"class":136,"line":493},[134,4668,1160],{"emptyLinePlaceholder":43},[134,4670,4671,4673,4675,4677],{"class":136,"line":499},[134,4672,4539],{"class":265},[134,4674,1753],{"class":1112},[134,4676,275],{"class":265},[134,4678,4679],{"class":143},"Install Dependencies and Build\n",[134,4681,4682,4684,4686],{"class":136,"line":505},[134,4683,4659],{"class":1112},[134,4685,275],{"class":265},[134,4687,4688],{"class":1112},"|\n",[134,4690,4691],{"class":136,"line":1908},[134,4692,4693],{"class":143},"                  npm install\n",[134,4695,4696],{"class":136,"line":1913},[134,4697,4698],{"class":143},"                  npm run generate\n",[134,4700,4701],{"class":136,"line":1919},[134,4702,1160],{"emptyLinePlaceholder":43},[134,4704,4705,4707,4709,4711],{"class":136,"line":1924},[134,4706,4539],{"class":265},[134,4708,1753],{"class":1112},[134,4710,275],{"class":265},[134,4712,4713],{"class":143},"Compress Build Folder\n",[134,4715,4716,4718,4720],{"class":136,"line":1930},[134,4717,4659],{"class":1112},[134,4719,275],{"class":265},[134,4721,4688],{"class":1112},[134,4723,4724],{"class":136,"line":1935},[134,4725,4726],{"class":143},"                  cd ./.output\n",[134,4728,4729],{"class":136,"line":1941},[134,4730,4731],{"class":143},"                  tar -czvf build.tar.gz ./public\n",[134,4733,4734],{"class":136,"line":1946},[134,4735,1160],{"emptyLinePlaceholder":43},[134,4737,4738,4740,4742,4744],{"class":136,"line":1958},[134,4739,4539],{"class":265},[134,4741,1753],{"class":1112},[134,4743,275],{"class":265},[134,4745,4746],{"class":143},"Copy to Server\n",[134,4748,4749,4751,4753],{"class":136,"line":1963},[134,4750,4659],{"class":1112},[134,4752,275],{"class":265},[134,4754,4688],{"class":1112},[134,4756,4757],{"class":136,"line":1969},[134,4758,4726],{"class":143},[134,4760,4761],{"class":136,"line":1974},[134,4762,4763],{"class":143},"                  echo \"${{ secrets.SSH_PRIVATE_KEY }}\" > id_rsa\n",[134,4765,4766],{"class":136,"line":1980},[134,4767,4768],{"class":143},"                  chmod 600 id_rsa\n",[134,4770,4771],{"class":136,"line":1985},[134,4772,4773],{"class":143},"                  scp -o StrictHostKeyChecking=no -i id_rsa build.tar.gz ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/var/www/project/build.tar.gz\n",[134,4775,4776],{"class":136,"line":1991},[134,4777,1160],{"emptyLinePlaceholder":43},[134,4779,4780,4782,4784,4786],{"class":136,"line":1996},[134,4781,4539],{"class":265},[134,4783,1753],{"class":1112},[134,4785,275],{"class":265},[134,4787,4788],{"class":143},"Extract Build Folder On Server\n",[134,4790,4791,4793,4795],{"class":136,"line":2002},[134,4792,4659],{"class":1112},[134,4794,275],{"class":265},[134,4796,4688],{"class":1112},[134,4798,4799],{"class":136,"line":2007},[134,4800,4726],{"class":143},[134,4802,4803],{"class":136,"line":2013},[134,4804,4805],{"class":143},"                  ssh -i id_rsa -o StrictHostKeyChecking=no ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} \u003C\u003C 'EOF'\n",[134,4807,4808],{"class":136,"line":2018},[134,4809,4810],{"class":143},"                  cd /var/www/project\n",[134,4812,4813],{"class":136,"line":2024},[134,4814,4815],{"class":143},"                  tar -xzvf build.tar.gz\n",[134,4817,4818],{"class":136,"line":2029},[134,4819,4820],{"class":143},"                  rm build.tar.gz\n",[134,4822,4823],{"class":136,"line":2035},[134,4824,4825],{"class":143},"                  mv website website_old\n",[134,4827,4828],{"class":136,"line":2040},[134,4829,4830],{"class":143},"                  mv public website\n",[134,4832,4833],{"class":136,"line":2046},[134,4834,4835],{"class":143},"                  rm -rf website_old\n",[134,4837,4838],{"class":136,"line":2051},[134,4839,4840],{"class":143},"                  EOF\n",[99,4842,4844],{"id":4843},"the-workflow-file-explained","The Workflow File Explained",[11,4846,4847],{},"Let's examine each section of the workflow file:",[115,4849,4851],{"id":4850},"trigger-configuration","Trigger Configuration",[126,4853,4855],{"className":4495,"code":4854,"language":4497,"meta":34,"style":34},"name: Deploy to DigitalOcean\n\non:\n    push:\n        branches:\n            - main\n",[59,4856,4857,4865,4869,4875,4881,4887],{"__ignoreMap":34},[134,4858,4859,4861,4863],{"class":136,"line":137},[134,4860,1753],{"class":1112},[134,4862,275],{"class":265},[134,4864,4508],{"class":143},[134,4866,4867],{"class":136,"line":35},[134,4868,1160],{"emptyLinePlaceholder":43},[134,4870,4871,4873],{"class":136,"line":169},[134,4872,4517],{"class":150},[134,4874,4520],{"class":265},[134,4876,4877,4879],{"class":136,"line":297},[134,4878,4525],{"class":1112},[134,4880,4520],{"class":265},[134,4882,4883,4885],{"class":136,"line":310},[134,4884,4532],{"class":1112},[134,4886,4520],{"class":265},[134,4888,4889,4891],{"class":136,"line":323},[134,4890,4539],{"class":265},[134,4892,4542],{"class":143},[11,4894,4895,4896,4898],{},"This section gives the workflow a name and defines when it should run—specifically, when code is pushed to the ",[59,4897,4413],{}," branch. Every push to that branch will trigger the deployment, while pushes to any other branch will not.",[115,4900,4902],{"id":4901},"the-deploy-job","The Deploy Job",[126,4904,4906],{"className":4495,"code":4905,"language":4497,"meta":34,"style":34},"jobs:\n    deploy:\n        runs-on: ubuntu-latest\n",[59,4907,4908,4914,4920],{"__ignoreMap":34},[134,4909,4910,4912],{"class":136,"line":137},[134,4911,4551],{"class":1112},[134,4913,4520],{"class":265},[134,4915,4916,4918],{"class":136,"line":35},[134,4917,4558],{"class":1112},[134,4919,4520],{"class":265},[134,4921,4922,4924,4926],{"class":136,"line":169},[134,4923,4565],{"class":1112},[134,4925,275],{"class":265},[134,4927,4570],{"class":143},[11,4929,4930,4931,4933,4934,4937,4938,4941,4942,4945],{},"After the name and trigger configuration, we define the ",[59,4932,4551],{}," we need to run. In our case, we need a single job called ",[59,4935,4936],{},"deploy",". The ",[59,4939,4940],{},"runs-on"," property determines the type of environment required—in this case, the latest Ubuntu runner provided by GitHub. GitHub Actions uses these ",[110,4943,4944],{},"virtual environments"," to execute workflow steps. You can think of this as creating a fresh Ubuntu installation that runs the job and is then destroyed.",[115,4947,4949],{"id":4948},"step-1-checkout-the-code","Step 1: Checkout the Code",[126,4951,4953],{"className":4495,"code":4952,"language":4497,"meta":34,"style":34},"steps:\n    - name: Checkout Code\n      uses: actions/checkout@v4\n",[59,4954,4955,4962,4973],{"__ignoreMap":34},[134,4956,4957,4960],{"class":136,"line":137},[134,4958,4959],{"class":1112},"steps",[134,4961,4520],{"class":265},[134,4963,4964,4967,4969,4971],{"class":136,"line":35},[134,4965,4966],{"class":265},"    - ",[134,4968,1753],{"class":1112},[134,4970,275],{"class":265},[134,4972,4588],{"class":143},[134,4974,4975,4978,4980],{"class":136,"line":169},[134,4976,4977],{"class":1112},"      uses",[134,4979,275],{"class":265},[134,4981,4598],{"class":143},[11,4983,4984,4985,4988,4989,4992],{},"Alright, first step! This uses the official ",[59,4986,4987],{},"checkout"," action to fetch your repository's code into the runner. It's essentially equivalent to a ",[59,4990,4991],{},"git clone"," operation. By default, it places all repository files in the root directory of the workflow runner, making them immediately accessible to subsequent steps.",[115,4994,4996],{"id":4995},"step-2-install-node","Step 2: Install Node",[126,4998,5000],{"className":4495,"code":4999,"language":4497,"meta":34,"style":34},"- name: Setup Node.js\n  uses: actions/setup-node@v4\n  with:\n      node-version: '22'\n",[59,5001,5002,5013,5022,5029],{"__ignoreMap":34},[134,5003,5004,5007,5009,5011],{"class":136,"line":137},[134,5005,5006],{"class":265},"- ",[134,5008,1753],{"class":1112},[134,5010,275],{"class":265},[134,5012,4613],{"class":143},[134,5014,5015,5018,5020],{"class":136,"line":35},[134,5016,5017],{"class":1112},"  uses",[134,5019,275],{"class":265},[134,5021,4622],{"class":143},[134,5023,5024,5027],{"class":136,"line":169},[134,5025,5026],{"class":1112},"  with",[134,5028,4520],{"class":265},[134,5030,5031,5034,5036],{"class":136,"line":297},[134,5032,5033],{"class":1112},"      node-version",[134,5035,275],{"class":265},[134,5037,4639],{"class":143},[11,5039,2530,5040,5043],{},[59,5041,5042],{},"actions/setup-node@v4"," is an official GitHub action that handles the installation and configuration of Node.js in your workflow environment. Without this step, your runner would either have no Node.js installed or would use whatever default version is pre-installed on the Ubuntu runner (which might not match your application's requirements).",[11,5045,2530,5046,5049,5050,5053],{},[59,5047,5048],{},"with:"," section specifies parameters for the action, and ",[59,5051,5052],{},"node-version: '22'"," tells the action specifically which version of Node.js to install.",[115,5055,5057],{"id":5056},"step-3-create-env-file","Step 3: Create env File",[126,5059,5061],{"className":4495,"code":5060,"language":4497,"meta":34,"style":34},"- name: Create .env File\n  run: echo \"${{ secrets.ENV }}\" > .env\n",[59,5062,5063,5073],{"__ignoreMap":34},[134,5064,5065,5067,5069,5071],{"class":136,"line":137},[134,5066,5006],{"class":265},[134,5068,1753],{"class":1112},[134,5070,275],{"class":265},[134,5072,4654],{"class":143},[134,5074,5075,5078,5080],{"class":136,"line":35},[134,5076,5077],{"class":1112},"  run",[134,5079,275],{"class":265},[134,5081,4664],{"class":143},[11,5083,5084,5085,5088,5089,5091],{},"Now, our application might rely on environment variables, typically stored in an ",[59,5086,5087],{},".env"," file. Since this file isn't part of our repository, it won't exist in the runner's environment. If the build process depends on the ",[59,5090,5087],{}," file, we need to create it first.",[11,5093,5094,5095,5097],{},"How can we do that? In a conventional development workflow, you might create the ",[59,5096,5087],{}," file manually. But in an automated CI/CD pipeline, you need a secure way to provide these values without exposing them in your code repository.",[11,5099,5100,5101,5104,5105,5108],{},"This is where ",[56,5102,5103],{},"GitHub Secrets"," come in. When you store a value as a GitHub Secret (in this case, a secret named ",[59,5106,5107],{},"ENV","), it's encrypted and only accessible during workflow execution. The secret isn't visible in logs, and even users with repository access can't view its content directly.",[11,5110,5111,5112,5114,5115,5117],{},"You can manage your repository's secrets from the GitHub dashboard. In this case, we store exactly what we need for the ",[59,5113,5087],{}," file in an ",[59,5116,5107],{}," secret:",[126,5119,5122],{"className":5120,"code":5121,"language":2598},[2596],"NUXT_PUBLIC_SITE_URL=https://project.com\nPOSTHOG_API_HOST=https://us.i.posthog.com\nPOSTHOG_API_KEY=XXXXXXXXXXXXXXXXXX\n",[59,5123,5121],{"__ignoreMap":34},[11,5125,5126,5127,5130,5131,5133,5134,5136],{},"The command ",[59,5128,5129],{},"echo \"${{ secrets.ENV }}\" > .env"," writes the contents of the ",[59,5132,5107],{}," secret into a new ",[59,5135,5087],{}," file in the root of your project directory. Perfect! Let's continue.",[115,5138,5140],{"id":5139},"step-4-generate-the-static-site","Step 4: Generate the Static Site",[126,5142,5144],{"className":4495,"code":5143,"language":4497,"meta":34,"style":34},"- name: Install Dependencies and Build\n  run: |\n      npm install\n      npm run generate\n",[59,5145,5146,5156,5164,5169],{"__ignoreMap":34},[134,5147,5148,5150,5152,5154],{"class":136,"line":137},[134,5149,5006],{"class":265},[134,5151,1753],{"class":1112},[134,5153,275],{"class":265},[134,5155,4679],{"class":143},[134,5157,5158,5160,5162],{"class":136,"line":35},[134,5159,5077],{"class":1112},[134,5161,275],{"class":265},[134,5163,4688],{"class":1112},[134,5165,5166],{"class":136,"line":169},[134,5167,5168],{"class":143},"      npm install\n",[134,5170,5171],{"class":136,"line":297},[134,5172,5173],{"class":143},"      npm run generate\n",[11,5175,5176,5177,5179,5180,5183,5184,5187,5188,5191],{},"Here, the workflow runs ",[59,5178,4435],{}," to install all the Node.js dependencies. Then, it runs the ",[59,5181,5182],{},"generate"," script defined in our ",[59,5185,5186],{},"package.json",", which compiles the code and generates all the static files for our website, storing them in the ",[59,5189,5190],{},".output/public"," directory.",[115,5193,5195],{"id":5194},"step-5-compress-the-files","Step 5: Compress the Files",[11,5197,5198],{},"Great! We have our static site generated. Now, we need a way to move those files to a public server so they are accessible on the internet. Remember, the runner's environment is temporary and gets destroyed as soon as the workflow completes, so we must export our site files to a permanent location. To do this efficiently, let's compress them into a single archive:",[126,5200,5202],{"className":4495,"code":5201,"language":4497,"meta":34,"style":34},"- name: Compress Build Folder\n  run: |\n      cd ./.output\n      tar -czvf build.tar.gz ./public\n",[59,5203,5204,5214,5222,5227],{"__ignoreMap":34},[134,5205,5206,5208,5210,5212],{"class":136,"line":137},[134,5207,5006],{"class":265},[134,5209,1753],{"class":1112},[134,5211,275],{"class":265},[134,5213,4713],{"class":143},[134,5215,5216,5218,5220],{"class":136,"line":35},[134,5217,5077],{"class":1112},[134,5219,275],{"class":265},[134,5221,4688],{"class":1112},[134,5223,5224],{"class":136,"line":169},[134,5225,5226],{"class":143},"      cd ./.output\n",[134,5228,5229],{"class":136,"line":297},[134,5230,5231],{"class":143},"      tar -czvf build.tar.gz ./public\n",[11,5233,5234,5235,5237,5238,5241,5242,5245],{},"Here, the workflow navigates to the ",[59,5236,3877],{}," directory and creates a compressed tarball (",[59,5239,5240],{},"build.tar.gz",") of the ",[59,5243,5244],{},"public"," directory. Compressing the files makes the transfer to your server faster and more reliable.",[115,5247,5249],{"id":5248},"step-6-copy-the-compressed-files-to-the-server","Step 6: Copy the Compressed Files to the Server",[11,5251,5252],{},"We are in the final stretch: we need to move our compressed files to the web server and extract them. This step handles the first part.",[11,5254,5255,5256,5259,5260,5263,5264,5266,5267,5270,5271,5274,5275,2651],{},"How can we copy a file between servers? In Linux, we can use the ",[59,5257,5258],{},"scp"," command, which stands for ",[56,5261,5262],{},"\"secure copy\"",". It takes two main parameters: the path of the local file and the path of the destination. For example, to copy ",[59,5265,5240],{}," into the ",[59,5268,5269],{},"/var/www/my-site"," folder on a server with the IP ",[59,5272,5273],{},"192.168.123.45",", using the user ",[59,5276,5277],{},"myuser",[126,5279,5281],{"className":128,"code":5280,"language":130,"meta":34,"style":34},"scp build.tar.gz myuser@192.168.123.45:/var/www/my-site\n",[59,5282,5283],{"__ignoreMap":34},[134,5284,5285,5287,5290],{"class":136,"line":137},[134,5286,5258],{"class":140},[134,5288,5289],{"class":143}," build.tar.gz",[134,5291,5292],{"class":143}," myuser@192.168.123.45:/var/www/my-site\n",[11,5294,5295],{},"Now, the server will ask for authentication—otherwise, anyone could upload files to our server, and we definitely don't want that! To authenticate our deployment process securely, we'll use an SSH key.",[11,5297,5298,5299,2448,5302,5305],{},"An SSH key consists of two parts: a ",[56,5300,5301],{},"public key",[56,5303,5304],{},"private key",". Think of the public key as a lock installed on your server's door, while the private key is the unique key that opens that specific lock. The public key will live on the server, and the private key stays securely on the machine attempting to connect to it (in this case, our GitHub Actions runner).",[11,5307,5308],{},"Here's how you can create and setup an SHH key, step by step:",[5310,5311,5313],"h4",{"id":5312},"generate-an-ssh-key-on-your-local-computer","Generate an SSH key on your local computer:",[126,5315,5317],{"className":128,"code":5316,"language":130,"meta":34,"style":34},"ssh-keygen -t ed25519 -C \"your-email@example.com\"\n",[59,5318,5319],{"__ignoreMap":34},[134,5320,5321,5324,5327,5330,5333],{"class":136,"line":137},[134,5322,5323],{"class":140},"ssh-keygen",[134,5325,5326],{"class":150}," -t",[134,5328,5329],{"class":143}," ed25519",[134,5331,5332],{"class":150}," -C",[134,5334,5335],{"class":143}," \"your-email@example.com\"\n",[11,5337,5338,5339,5342,5343,5346,5347,734],{},"Press ",[56,5340,5341],{},"Enter"," to accept the defaults. For automation purposes like GitHub Actions, we typically won't set up a passphrase. This creates two files: a public key (stored in ",[59,5344,5345],{},"~/.ssh/id_ed25519.pub",") and a private key (in ",[59,5348,5349],{},"~/.ssh/id_ed25519",[5310,5351,5353],{"id":5352},"view-and-copy-the-public-key-to-your-clipboard","View and copy the public key to your clipboard:",[126,5355,5357],{"className":128,"code":5356,"language":130,"meta":34,"style":34},"cat ~/.ssh/id_ed25519.pub\n",[59,5358,5359],{"__ignoreMap":34},[134,5360,5361,5364],{"class":136,"line":137},[134,5362,5363],{"class":140},"cat",[134,5365,5366],{"class":143}," ~/.ssh/id_ed25519.pub\n",[11,5368,5369],{},"This command will output the public key. Copy it to your clipboard. The output will look something like:",[126,5371,5373],{"className":128,"code":5372,"language":130,"meta":34,"style":34},"ssh-ed25519 AAAAC3N...6OdN/x8L1fgmt your-email@example.com\n",[59,5374,5375],{"__ignoreMap":34},[134,5376,5377,5380,5383],{"class":136,"line":137},[134,5378,5379],{"class":140},"ssh-ed25519",[134,5381,5382],{"class":143}," AAAAC3N...6OdN/x8L1fgmt",[134,5384,5385],{"class":143}," your-email@example.com\n",[5310,5387,5389],{"id":5388},"add-the-public-key-to-your-digitalocean-droplet","Add the public key to your DigitalOcean droplet:",[64,5391,5392,5405,5412],{},[67,5393,5394,5395,5398,5399,5398,5402],{},"Go to ",[56,5396,5397],{},"DigitalOcean Dashboard"," → ",[56,5400,5401],{},"Settings",[56,5403,5404],{},"Security",[67,5406,5407,5408,5411],{},"Click ",[56,5409,5410],{},"Add SSH Key",", paste the public key, and save.",[67,5413,5414,5415,2435],{},"Alternatively, you can add it directly to your server's ",[59,5416,5417],{},"~/.ssh/authorized_keys",[5310,5419,5421],{"id":5420},"set-up-the-private-key-and-connection-details-in-github","Set up the private key and connection details in GitHub:",[64,5423,5424,5435],{},[67,5425,5426,5427,5398,5429,5398,5432],{},"Go to your GitHub repository → ",[56,5428,5401],{},[56,5430,5431],{},"Secrets and variables",[56,5433,5434],{},"Actions",[67,5436,5437,5438],{},"Add the following repository secrets:\n",[64,5439,5440,5449,5458],{},[67,5441,5442,5445,5446,5448],{},[59,5443,5444],{},"SSH_PRIVATE_KEY",": The entire contents of your private key file (",[59,5447,5349],{},")",[67,5450,5451,5454,5455,5448],{},[59,5452,5453],{},"DROPLET_IP",": Your DigitalOcean droplet's IP address (e.g., ",[59,5456,5457],{},"123.456.789.0",[67,5459,5460,5463,5464,5448],{},[59,5461,5462],{},"DROPLET_USER",": The username you use to log into your server (e.g. ",[59,5465,5466],{},"nico",[11,5468,5469,5470,5472],{},"Now it's time to copy our site to the web server using ",[59,5471,5258],{},". Here's the code for that step:",[126,5474,5476],{"className":4495,"code":5475,"language":4497,"meta":34,"style":34},"- name: Copy to Server\n  run: |\n      cd ./.output\n      echo \"${{ secrets.SSH_PRIVATE_KEY }}\" > id_rsa\n      chmod 600 id_rsa\n      scp -o StrictHostKeyChecking=no -i id_rsa build.tar.gz ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/var/www/project/build.tar.gz\n",[59,5477,5478,5488,5496,5500,5505,5510],{"__ignoreMap":34},[134,5479,5480,5482,5484,5486],{"class":136,"line":137},[134,5481,5006],{"class":265},[134,5483,1753],{"class":1112},[134,5485,275],{"class":265},[134,5487,4746],{"class":143},[134,5489,5490,5492,5494],{"class":136,"line":35},[134,5491,5077],{"class":1112},[134,5493,275],{"class":265},[134,5495,4688],{"class":1112},[134,5497,5498],{"class":136,"line":169},[134,5499,5226],{"class":143},[134,5501,5502],{"class":136,"line":297},[134,5503,5504],{"class":143},"      echo \"${{ secrets.SSH_PRIVATE_KEY }}\" > id_rsa\n",[134,5506,5507],{"class":136,"line":310},[134,5508,5509],{"class":143},"      chmod 600 id_rsa\n",[134,5511,5512],{"class":136,"line":323},[134,5513,5514],{"class":143},"      scp -o StrictHostKeyChecking=no -i id_rsa build.tar.gz ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }}:/var/www/project/build.tar.gz\n",[11,5516,5517],{},"This critical step transfers our build to the DigitalOcean server:",[5519,5520,5521,5530,5536,5543,5549],"ol",{},[67,5522,5523,5524,5526,5527,5529],{},"It changes the current directory to ",[59,5525,3877],{},", where our ",[59,5528,5240],{}," is stored.",[67,5531,5532,5533,5535],{},"It creates a temporary SSH key file from the ",[59,5534,5444],{}," secret.",[67,5537,5538,5539,5542],{},"Sets the correct permissions on the key file: ",[59,5540,5541],{},"600",", which grants read and write access for the owner only.",[67,5544,5545,5546,5548],{},"Uses ",[59,5547,5258],{}," to transfer the compressed build file to the server.",[67,5550,2530,5551,5554],{},[59,5552,5553],{},"-o StrictHostKeyChecking=no"," option skips the host verification prompt.",[115,5556,5558],{"id":5557},"step-7-uncompress-files","Step 7: Uncompress Files",[11,5560,5561,5562,5564],{},"We are almost there! If the previous step was successful, the ",[59,5563,5240],{}," should be stored on our server. There are just a few things left to do:",[64,5566,5567,5576,5579,5592],{},[67,5568,5569,5570,5572,5573,5575],{},"Uncompress ",[59,5571,5240],{},", which will create a ",[59,5574,5244],{}," folder.",[67,5577,5578],{},"Remove the compressed file (just to keep things clean).",[67,5580,5581,5582,5585,5586,5588,5589,548],{},"Rename the directories: the old website version folder should be renamed ",[59,5583,5584],{},"website_old",", and the ",[59,5587,5244],{}," folder will be renamed ",[59,5590,5591],{},"website",[67,5593,5594,5595,5597],{},"Remove the ",[59,5596,5584],{}," folder (again, to keep things clean). You can decide to keep it for a while in case you need to roll back if something goes wrong.",[11,5599,5600,5601,5604,5605,5608,5609,734],{},"We need to execute this chain of commands on the web server. To do so, we can use ",[59,5602,5603],{},"ssh"," to establish an SSH connection to the server using the key file we created in the previous step, ",[59,5606,5607],{},"id_rsa",". Then, we can pass the commands to execute in a string using heredoc (",[59,5610,5611],{},"\u003C\u003C 'EOF'",[126,5613,5615],{"className":4495,"code":5614,"language":4497,"meta":34,"style":34},"- name: Extract Build Folder On Server\n  run: |\n      cd ./.output\n      ssh -i id_rsa -o StrictHostKeyChecking=no ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} \u003C\u003C 'EOF'\n      cd /var/www/project\n      tar -xzvf build.tar.gz\n      rm build.tar.gz\n      mv website website_old\n      mv public website\n      rm -rf website_old\n      EOF\n",[59,5616,5617,5627,5635,5639,5644,5649,5654,5659,5664,5669,5674],{"__ignoreMap":34},[134,5618,5619,5621,5623,5625],{"class":136,"line":137},[134,5620,5006],{"class":265},[134,5622,1753],{"class":1112},[134,5624,275],{"class":265},[134,5626,4788],{"class":143},[134,5628,5629,5631,5633],{"class":136,"line":35},[134,5630,5077],{"class":1112},[134,5632,275],{"class":265},[134,5634,4688],{"class":1112},[134,5636,5637],{"class":136,"line":169},[134,5638,5226],{"class":143},[134,5640,5641],{"class":136,"line":297},[134,5642,5643],{"class":143},"      ssh -i id_rsa -o StrictHostKeyChecking=no ${{ secrets.DROPLET_USER }}@${{ secrets.DROPLET_IP }} \u003C\u003C 'EOF'\n",[134,5645,5646],{"class":136,"line":310},[134,5647,5648],{"class":143},"      cd /var/www/project\n",[134,5650,5651],{"class":136,"line":323},[134,5652,5653],{"class":143},"      tar -xzvf build.tar.gz\n",[134,5655,5656],{"class":136,"line":332},[134,5657,5658],{"class":143},"      rm build.tar.gz\n",[134,5660,5661],{"class":136,"line":338},[134,5662,5663],{"class":143},"      mv website website_old\n",[134,5665,5666],{"class":136,"line":351},[134,5667,5668],{"class":143},"      mv public website\n",[134,5670,5671],{"class":136,"line":364},[134,5672,5673],{"class":143},"      rm -rf website_old\n",[134,5675,5676],{"class":136,"line":377},[134,5677,5678],{"class":143},"      EOF\n",[11,5680,5681],{},"This approach creates a seamless deployment by swapping directories rather than overwriting files, minimizing downtime.",[99,5683,5685],{"id":5684},"what-makes-this-workflow-effective","What Makes This Workflow Effective",[5519,5687,5688,5693,5699],{},[67,5689,5690,5692],{},[56,5691,5404],{},": All sensitive information is stored as GitHub secrets",[67,5694,5695,5698],{},[56,5696,5697],{},"Zero Downtime",": The site remains available during deployment with the directory swap technique",[67,5700,5701,5704],{},[56,5702,5703],{},"Automation",": The entire process runs automatically on every push to main",[99,5706,5708],{"id":5707},"required-github-secrets","Required GitHub Secrets",[11,5710,5711],{},"For this workflow to function, you need to configure these GitHub secrets:",[64,5713,5714,5719,5724,5729],{},[67,5715,5716,5718],{},[59,5717,5107],{},": Your application's environment variables",[67,5720,5721,5723],{},[59,5722,5444],{},": The private SSH key for accessing your server",[67,5725,5726,5728],{},[59,5727,5453],{},": Your DigitalOcean droplet's IP address",[67,5730,5731,5733],{},[59,5732,5462],{},": The username for SSH access to your server",[99,5735,5737],{"id":5736},"conclusion","Conclusion",[11,5739,5740,5741,5743],{},"This GitHub Action workflow creates a robust CI/CD pipeline that automatically builds and deploys your application to DigitalOcean whenever you update your ",[59,5742,4413],{}," branch. It manages environment variables securely, ensures a smooth deployment process, and minimizes potential downtime.",[11,5745,5746],{},"It will save you valuable time, letting you focus on what's most important: developing your app.",[11,5748,5749,5750,5755],{},"Want more GitHub Actions tips and tricks? ",[21,5751,5754],{"href":5752,"rel":5753},"https://x.com/nicodevs",[25],"Let me know",". Until next time!",[1356,5757,5758],{},"html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}",{"title":34,"searchDepth":35,"depth":35,"links":5760},[5761,5762,5773,5774,5775],{"id":4466,"depth":35,"text":4467},{"id":4843,"depth":35,"text":4844,"children":5763},[5764,5765,5766,5767,5768,5769,5770,5771,5772],{"id":4850,"depth":169,"text":4851},{"id":4901,"depth":169,"text":4902},{"id":4948,"depth":169,"text":4949},{"id":4995,"depth":169,"text":4996},{"id":5056,"depth":169,"text":5057},{"id":5139,"depth":169,"text":5140},{"id":5194,"depth":169,"text":5195},{"id":5248,"depth":169,"text":5249},{"id":5557,"depth":169,"text":5558},{"id":5684,"depth":35,"text":5685},{"id":5707,"depth":35,"text":5708},{"id":5736,"depth":35,"text":5737},"2025-02-25","Auto-deploy is one of the great features of Netlify and Vercel. Whenever you push to main... done! Your site builds automatically. I love that, but I prefer to manage my own server.","/img/blog/nuxt-deploy-github-actions-digital-ocean.png",{},"/blog/automatically-deploy-your-nuxt-static-site-to-digitalocean-with-github-actions",{"title":4405,"description":5777},"blog/automatically-deploy-your-nuxt-static-site-to-digitalocean-with-github-actions","Za_WYOcttKXUUcelpG6SkefjbPySNWXQ7o7f1mkWuPg",{"id":5785,"title":5786,"body":5787,"category":4393,"date":6840,"description":5791,"draft":39,"extension":40,"image":6841,"meta":6842,"navigation":43,"path":6843,"seo":6844,"stem":6845,"tighten":39,"__hash__":6846},"blog/blog/a-step-by-step-guide-to-setting-up-permissions-on-your-linux-web-server.md","A Step-by-Step Guide to Setting Up Permissions on Your Linux Web Server",{"type":8,"value":5788,"toc":6827},[5789,5792,5799,5802,5806,5819,5824,5844,5849,5870,5873,5915,5921,5931,5942,5965,5969,5984,5999,6002,6016,6023,6040,6043,6047,6054,6059,6062,6105,6108,6123,6137,6140,6154,6168,6173,6176,6194,6199,6213,6216,6230,6240,6254,6260,6275,6278,6282,6288,6305,6319,6328,6335,6349,6352,6363,6372,6379,6383,6395,6416,6426,6430,6433,6493,6497,6514,6537,6541,6555,6561,6572,6576,6586,6595,6608,6624,6650,6672,6682,6694,6710,6719,6731,6734,6750,6754,6757,6772,6775,6811,6814,6816,6819,6822,6824],[11,5790,5791],{},"Web server permissions: setting them right can be challenging, but setting them wrong can be disastrous. Of course, we don't want to grant a third party access to private files and folders or allow the execution of malicious code.",[11,5793,5794,5795,5798],{},"It's frustrating when your users encounter a ",[56,5796,5797],{},"403 Forbidden"," error while visiting your website or trying to download a file. But trust me, the solution is never to 'just set the permissions to 777,' despite how often I've seen that advice online.",[11,5800,5801],{},"Want to secure the permissions of your Linux web server once and for all? Then this guide is for you!",[99,5803,5805],{"id":5804},"a-quick-rundown","A Quick Rundown",[11,5807,5808,5809,621,5811,5814,5815,5818],{},"In Linux, the operating system can have many users. Each user can belong to multiple groups, and permissions determine what actions a given user or group can perform on a specific file or folder. These actions are ",[56,5810,716],{},[56,5812,5813],{},"write",", and ",[56,5816,5817],{},"execute",". The names are quite self-explanatory, but let's dive into what they actually mean:",[11,5820,5821],{},[56,5822,5823],{},"For Files:",[64,5825,5826,5832,5838],{},[67,5827,5828,5831],{},[56,5829,5830],{},"Read (r):"," View the contents of the file.",[67,5833,5834,5837],{},[56,5835,5836],{},"Write (w):"," Modify the contents of the file.",[67,5839,5840,5843],{},[56,5841,5842],{},"Execute (x):"," Run the file (e.g., a bash script).",[11,5845,5846],{},[56,5847,5848],{},"For Directories:",[64,5850,5851,5856,5861],{},[67,5852,5853,5855],{},[56,5854,5830],{}," List the contents of the directory.",[67,5857,5858,5860],{},[56,5859,5836],{}," Create, delete, or modify files within the directory.",[67,5862,5863,5865,5866,5869],{},[56,5864,5842],{}," Navigate (",[59,5867,5868],{},"cd",") into the directory. This means you can access the directory and perform actions on its contents, but not necessarily list them without read permission.",[11,5871,5872],{},"Each file and folder has three sets of these permissions:",[64,5874,5875,5886,5901],{},[67,5876,5877,5878,5881,5882,5885],{},"The first set determines what the ",[56,5879,5880],{},"owner"," can do. By default, the owner of a file or folder is the user who created it. Normally, the owner has ",[59,5883,5884],{},"rwx"," (read, write, and execute) permissions.",[67,5887,5888,5889,5892,5893,5896,5897,5900],{},"The second set determines what ",[56,5890,5891],{},"other users in the same group as the owner"," can do. These permissions can be more restrictive. For example, you might want these users to be able to read and write the file but not execute it, resulting in ",[59,5894,5895],{},"rw-"," (the hyphen in place of the ",[59,5898,5899],{},"x"," means they lack that permission).",[67,5902,5903,5904,5907,5908,5911,5912,734],{},"The third set determines what ",[56,5905,5906],{},"any other user"," can do. Perhaps you want anyone to only read a file (",[59,5909,5910],{},"r--","), or you might want to forbid all actions (",[59,5913,5914],{},"---",[11,5916,5917,5918,548],{},"These three sets of permissions are expressed as a single concatenated string, such as ",[59,5919,5920],{},"rwxrw-r--",[11,5922,5923,5924,5927,5928,5930],{},"Additionally, at the beginning of the string, you'll find a ",[59,5925,5926],{},"d"," if the item is a directory, or a ",[59,5929,1442],{}," if it is not.",[11,5932,5933,5934,5937,5938,5941],{},"For example, the permissions of your ",[59,5935,5936],{},"/var/www/html/"," directory might look like this: ",[59,5939,5940],{},"drwxr-xr-x",". This means:",[64,5943,5944,5949,5954,5960],{},[67,5945,5946,5948],{},[59,5947,5926],{}," indicates that it is a directory. Remember, executing a directory means being able to open the folder.",[67,5950,5951,5953],{},[59,5952,5884],{}," means the owner can read, write, and execute.",[67,5955,5956,5959],{},[59,5957,5958],{},"r-x"," means users in the group can read and execute.",[67,5961,5962,5964],{},[59,5963,5958],{}," means any other user can read and execute.",[115,5966,5968],{"id":5967},"how-to-change-owner-and-group","How to Change Owner and Group",[11,5970,5971,5972,5975,5976,5979,5980,5983],{},"To change the owner and group of a file or directory, use the ",[59,5973,5974],{},"chown"," command. For example, to change the owner of ",[59,5977,5978],{},"file.txt"," to ",[59,5981,5982],{},"username",", run:",[126,5985,5987],{"className":128,"code":5986,"language":130,"meta":34,"style":34},"chown username file.txt\n",[59,5988,5989],{"__ignoreMap":34},[134,5990,5991,5993,5996],{"class":136,"line":137},[134,5992,5974],{"class":140},[134,5994,5995],{"class":143}," username",[134,5997,5998],{"class":143}," file.txt\n",[11,6000,6001],{},"To change both the owner and the group at once, use a colon to separate them:",[126,6003,6005],{"className":128,"code":6004,"language":130,"meta":34,"style":34},"chown username:groupname file.txt\n",[59,6006,6007],{"__ignoreMap":34},[134,6008,6009,6011,6014],{"class":136,"line":137},[134,6010,5974],{"class":140},[134,6012,6013],{"class":143}," username:groupname",[134,6015,5998],{"class":143},[11,6017,6018,6019,6022],{},"If you need to change the owner or group for an entire directory and its contents, add the ",[59,6020,6021],{},"-R"," option to apply changes recursively:",[126,6024,6026],{"className":128,"code":6025,"language":130,"meta":34,"style":34},"chown -R username:groupname /path/to/directory\n",[59,6027,6028],{"__ignoreMap":34},[134,6029,6030,6032,6035,6037],{"class":136,"line":137},[134,6031,5974],{"class":140},[134,6033,6034],{"class":150}," -R",[134,6036,6013],{"class":143},[134,6038,6039],{"class":143}," /path/to/directory\n",[11,6041,6042],{},"Always double-check your commands and file paths before running them to avoid unintentionally altering critical system files.",[115,6044,6046],{"id":6045},"how-to-change-permissions","How to Change Permissions",[11,6048,6049,6050,6053],{},"To modify file or directory permissions, use the ",[59,6051,6052],{},"chmod"," command. There are two common methods: numeric (octal) and symbolic.",[11,6055,6056],{},[56,6057,6058],{},"Numeric Method:",[11,6060,6061],{},"Permissions are set using numbers:",[64,6063,6064,6073,6081,6089,6097],{},[67,6065,6066,6069,6070,6072],{},[59,6067,6068],{},"7"," for ",[59,6071,5884],{}," (read, write, execute)",[67,6074,6075,6069,6078,6080],{},[59,6076,6077],{},"6",[59,6079,5895],{}," (read, write)",[67,6082,6083,6069,6086,6088],{},[59,6084,6085],{},"5",[59,6087,5958],{}," (read, execute)",[67,6090,6091,6069,6094,6096],{},[59,6092,6093],{},"4",[59,6095,5910],{}," (read only)",[67,6098,6099,6069,6102,6104],{},[59,6100,6101],{},"0",[59,6103,5914],{}," (no permissions)",[11,6106,6107],{},"For example, to set permissions for a folder so that the owner has full permissions and everyone else has read and execute, run:",[126,6109,6111],{"className":128,"code":6110,"language":130,"meta":34,"style":34},"chmod 755 my-folder/\n",[59,6112,6113],{"__ignoreMap":34},[134,6114,6115,6117,6120],{"class":136,"line":137},[134,6116,6052],{"class":140},[134,6118,6119],{"class":150}," 755",[134,6121,6122],{"class":143}," my-folder/\n",[11,6124,6125,6126,6128,6129,6132,6133,6136],{},"Additionally, we can add the digit ",[59,6127,318],{}," before the permission numbers: ",[59,6130,6131],{},"2755",". This is used to set the ",[56,6134,6135],{},"setgid"," (Set Group ID) permission on the directory, which makes files created within the directory inherit the group of the directory, rather than the group of the user who created the file.",[11,6138,6139],{},"For instance:",[126,6141,6143],{"className":128,"code":6142,"language":130,"meta":34,"style":34},"chmod 2755 my-folder/\n",[59,6144,6145],{"__ignoreMap":34},[134,6146,6147,6149,6152],{"class":136,"line":137},[134,6148,6052],{"class":140},[134,6150,6151],{"class":150}," 2755",[134,6153,6122],{"class":143},[11,6155,6156,6157,6160,6161,216,6164,6167],{},"This ensures that files created inside ",[59,6158,6159],{},"my-folder/"," will inherit the ",[56,6162,6163],{},"group ownership of the directory",[59,6165,6166],{},"www-data",", for example), which is especially useful for shared directories where multiple users need to write to the same location but maintain consistent group ownership.",[11,6169,6170],{},[56,6171,6172],{},"Symbolic Method:",[11,6174,6175],{},"This method allows you to add or remove specific permissions:",[64,6177,6178,6184,6189],{},[67,6179,6180,6183],{},[59,6181,6182],{},"+"," to add a permission",[67,6185,6186,6188],{},[59,6187,1442],{}," to remove a permission",[67,6190,6191,6193],{},[59,6192,1113],{}," to set the exact permission",[11,6195,6196,6197,5983],{},"For example, to add execute permission for the group on ",[59,6198,5978],{},[126,6200,6202],{"className":128,"code":6201,"language":130,"meta":34,"style":34},"chmod g+x file.txt\n",[59,6203,6204],{"__ignoreMap":34},[134,6205,6206,6208,6211],{"class":136,"line":137},[134,6207,6052],{"class":140},[134,6209,6210],{"class":143}," g+x",[134,6212,5998],{"class":143},[11,6214,6215],{},"Similarly, to remove write permission for others:",[126,6217,6219],{"className":128,"code":6218,"language":130,"meta":34,"style":34},"chmod o-w file.txt\n",[59,6220,6221],{"__ignoreMap":34},[134,6222,6223,6225,6228],{"class":136,"line":137},[134,6224,6052],{"class":140},[134,6226,6227],{"class":143}," o-w",[134,6229,5998],{"class":143},[11,6231,6232,6233,6235,6236,6239],{},"To set the ",[56,6234,6135],{}," (set group identity) permission on a directory, you can use the ",[59,6237,6238],{},"g+s"," flag. This ensures that files created inside the directory will inherit the directory's group:",[126,6241,6243],{"className":128,"code":6242,"language":130,"meta":34,"style":34},"chmod g+s my-folder/\n",[59,6244,6245],{"__ignoreMap":34},[134,6246,6247,6249,6252],{"class":136,"line":137},[134,6248,6052],{"class":140},[134,6250,6251],{"class":143}," g+s",[134,6253,6122],{"class":143},[11,6255,6256,6257,6259],{},"For directories, if you need to change permissions for all contained files and subdirectories, use the ",[59,6258,6021],{}," option:",[126,6261,6263],{"className":128,"code":6262,"language":130,"meta":34,"style":34},"chmod -R 755 my-folder/\n",[59,6264,6265],{"__ignoreMap":34},[134,6266,6267,6269,6271,6273],{"class":136,"line":137},[134,6268,6052],{"class":140},[134,6270,6034],{"class":150},[134,6272,6119],{"class":150},[134,6274,6122],{"class":143},[11,6276,6277],{},"These commands help ensure that your files and directories have the correct permissions to maintain both functionality and security.",[99,6279,6281],{"id":6280},"the-right-permissions-for-your-website-files","The Right Permissions for your Website Files",[11,6283,6284,6285],{},"Now that we're all on the same page, let's tackle the big question: ",[56,6286,6287],{},"What permissions should my website's files and folders have?",[11,6289,6290,6291,6293,6294,6296,6297,6300,6301,6304],{},"If you're using Nginx or Apache to serve your site, you're likely aware that these web servers run under their own user accounts. Typically, this user is called ",[59,6292,6166],{},", and it also has a corresponding group named ",[59,6295,6166],{},". The web server needs ",[56,6298,6299],{},"read access"," to files and folders that should be publicly accessible. In some cases, it also requires ",[56,6302,6303],{},"write access"," to certain subdirectories—such as when a PHP process needs to log data or when visitors are allowed to upload files to a temporary folder.",[11,6306,6307,6308,6311,6312,6314,6315,6318],{},"When you connect to your server via SSH, you're usually using your own user account. This could be the superuser ",[59,6309,6310],{},"root",", or a user you've created for yourself, like ",[59,6313,5466],{},". You might create a directory for your new side project, such as ",[59,6316,6317],{},"/var/www/side-project",", and then add files manually or pull them from a Git repository.",[11,6320,6321,6322,6324,6325,6327],{},"Here's the issue: if the web server user (",[59,6323,6166],{},") doesn't have permission to read a file owned by your user (",[59,6326,5466],{},"), the server will return an error when trying to access it.",[11,6329,6330,6331,6334],{},"How can we fix that? We can adjust the permissions of the directory containing all your websites (",[59,6332,6333],{},"/var/www",") and set the following:",[64,6336,6337,6343],{},[67,6338,6339,6340,6342],{},"Set your user (",[59,6341,5466],{},") as the owner.",[67,6344,6345,6346,6348],{},"Set the web server group (",[59,6347,6166],{},") as the group.",[11,6350,6351],{},"Then, determine the following permissions:",[64,6353,6354,6357,6360],{},[67,6355,6356],{},"The owner can read, write, and execute files and directories.",[67,6358,6359],{},"Any user in the group can read files, and read and execute directories.",[67,6361,6362],{},"Any other user does not have any permission over the files and directories.",[11,6364,6365,6366,6368,6369,6371],{},"This setup allows Nginx or Apache (",[59,6367,6166],{},") to read and serve files reliably, maintains security by preventing unauthorized access, and allows you (",[59,6370,5466],{},") to create, edit, and delete files without worrying about messing up your website.",[11,6373,6374,6375,6378],{},"Now, let's look at how we can achieve this setup, ensuring new files and directories ",[56,6376,6377],{},"inherit"," the correct permissions automatically, so that next time you create a folder for a new site, you won't have to adjust anything manually!",[115,6380,6382],{"id":6381},"step-1-assign-ownership","Step 1: Assign Ownership",[11,6384,6385,6386,6388,6389,6391,6392,6394],{},"First, let's assign ",[59,6387,5466],{}," as the owner and ",[59,6390,6166],{}," as the group for all files and directories inside ",[59,6393,6333],{},", recursively:",[126,6396,6398],{"className":128,"code":6397,"language":130,"meta":34,"style":34},"sudo chown -R nico:www-data /var/www\n",[59,6399,6400],{"__ignoreMap":34},[134,6401,6402,6405,6408,6410,6413],{"class":136,"line":137},[134,6403,6404],{"class":140},"sudo",[134,6406,6407],{"class":143}," chown",[134,6409,6034],{"class":150},[134,6411,6412],{"class":143}," nico:www-data",[134,6414,6415],{"class":143}," /var/www\n",[11,6417,6418,6419,522,6422,6425],{},"On some systems, the web server user might be different (e.g., ",[59,6420,6421],{},"www-html",[59,6423,6424],{},"apache","). If that's the case, simply adjust the command to reflect the correct user and group.",[115,6427,6429],{"id":6428},"step-2-configure-permissions-for-existing-files-and-folders","Step 2: Configure Permissions For Existing Files and Folders",[11,6431,6432],{},"Next, define the permissions. Directories and files require distinct settings:",[126,6434,6436],{"className":128,"code":6435,"language":130,"meta":34,"style":34},"sudo find /var/www -type d -exec chmod 2750 {} \\;\nsudo find /var/www -type f -exec chmod 0640 {} \\;\n",[59,6437,6438,6469],{"__ignoreMap":34},[134,6439,6440,6442,6445,6448,6451,6454,6457,6460,6463,6466],{"class":136,"line":137},[134,6441,6404],{"class":140},[134,6443,6444],{"class":143}," find",[134,6446,6447],{"class":143}," /var/www",[134,6449,6450],{"class":150}," -type",[134,6452,6453],{"class":143}," d",[134,6455,6456],{"class":150}," -exec",[134,6458,6459],{"class":143}," chmod",[134,6461,6462],{"class":150}," 2750",[134,6464,6465],{"class":143}," {}",[134,6467,6468],{"class":150}," \\;\n",[134,6470,6471,6473,6475,6477,6479,6482,6484,6486,6489,6491],{"class":136,"line":35},[134,6472,6404],{"class":140},[134,6474,6444],{"class":143},[134,6476,6447],{"class":143},[134,6478,6450],{"class":150},[134,6480,6481],{"class":143}," f",[134,6483,6456],{"class":150},[134,6485,6459],{"class":143},[134,6487,6488],{"class":150}," 0640",[134,6490,6465],{"class":143},[134,6492,6468],{"class":150},[5310,6494,6496],{"id":6495},"directories-2750","Directories: 2750",[64,6498,6499,6505,6511],{},[67,6500,6501,6502,6504],{},"The owner ",[59,6503,5466],{}," can read, write, and execute.",[67,6506,6507,6508,6510],{},"Users in the ",[59,6509,6166],{}," group can read and execute.",[67,6512,6513],{},"Others have no permissions.",[11,6515,6516,6517,6519,6520,6522,6523,6525,6526,6529,6530,6532,6533,6536],{},"The leading ",[59,6518,318],{}," (setgid bit) ensures new files and subdirectories inherit the ",[59,6521,6166],{}," group. For instance, if you're in ",[59,6524,6333],{}," and create a directory with ",[59,6527,6528],{},"mkdir my-site",", it will automatically belong to the ",[59,6531,6166],{}," group. Likewise, when you create a file with ",[59,6534,6535],{},"touch log.txt",", it will inherit the same group.",[5310,6538,6540],{"id":6539},"files-0640","Files: 0640",[64,6542,6543,6548,6553],{},[67,6544,6501,6545,6547],{},[59,6546,5466],{}," can read and write.",[67,6549,6507,6550,6552],{},[59,6551,6166],{}," group can only read.",[67,6554,6513],{},[11,6556,6557,6558,734],{},"These are good defaults but won't allow you to execute files. If you need to execute a script, manually add the execute permission (e.g. using ",[59,6559,6560],{},"chmod +x my-file",[11,6562,6563,6564,6567,6568,6571],{},"If you want to allow any user to enter directories and read files, you can opt for ",[59,6565,6566],{},"2775"," for directories and ",[59,6569,6570],{},"0644"," for files.",[115,6573,6575],{"id":6574},"step-3-use-umask-to-set-permissions-for-new-files-and-directories","Step 3: Use umask to Set Permissions for New Files and Directories",[11,6577,6578,6579,6581,6582,6585],{},"The setgid bit (indicated by the leading ",[59,6580,318],{}," in ",[59,6583,6584],{},"2750",") ensures that new subdirectories and files inherit the parent directory's group, but it does not modify their permission bits.",[11,6587,6588,6589,6567,6592,6571],{},"In Linux, files and directories are initially created with default permissions—typically ",[59,6590,6591],{},"777",[59,6593,6594],{},"666",[11,6596,6597,6598,4937,6601,6603,6604,6607],{},"These permissions are quite permissive but are modified by a default value called ",[59,6599,6600],{},"umask",[56,6602,6600],{}," (user file creation mask) specifies ",[56,6605,6606],{},"which permissions to remove"," from these defaults when new files and directories are created.",[11,6609,6610,6611,6613,6614,6617,6618,6620,6621,6623],{},"By default, ",[59,6612,6600],{}," is set to ",[59,6615,6616],{},"022",". You can check it by running the command ",[59,6619,6600],{}," in your terminal. What does ",[59,6622,6616],{}," mean?",[64,6625,6626,6632,6641],{},[67,6627,6628,6629,6631],{},"The first digit (",[59,6630,6101],{},") has no effect.",[67,6633,6634,6635,6637,6638,548],{},"The second digit (",[59,6636,318],{},") removes write permission for the ",[56,6639,6640],{},"group",[67,6642,6643,6644,6646,6647,548],{},"The third digit (",[59,6645,318],{},") removes write permission for ",[56,6648,6649],{},"others",[11,6651,6652,6653,6655,6656,216,6659,6662,6663,6665,6666,216,6669,734],{},"For new directories, it turns ",[59,6654,6591],{}," into ",[59,6657,6658],{},"755",[59,6660,6661],{},"rwxr-xr-x","). For new files, this default umask turns ",[59,6664,6594],{}," permissions into ",[59,6667,6668],{},"644",[59,6670,6671],{},"rw-r--r--",[11,6673,6674,6675,1068,6678,6681],{},"If those are the permissions you configured in the previous section, then you're all set! But if you went for the more restrictive permissions that remove all permissions for other users (",[59,6676,6677],{},"750",[59,6679,6680],{},"640","), you will need to tweak the last digit of umask.",[11,6683,6684,6685,6687,6688,6690,6691,548],{},"Which digit should we use? Well, if ",[59,6686,6068],{}," allows all permissions, then ",[59,6689,6068],{}," for umask means removing all those permissions. Remember, umask defines the permissions that won't be assigned by default. Therefore, we need to set the umask value to ",[59,6692,6693],{},"027",[64,6695,6696,6700,6705],{},[67,6697,6628,6698,6631],{},[59,6699,6101],{},[67,6701,6634,6702,6704],{},[59,6703,318],{},") removes the write permission for the group.",[67,6706,6643,6707,6709],{},[59,6708,6068],{},") removes the read, write, and execute permissions for others.",[11,6711,6712,6713,522,6716,2651],{},"Add the following line to your ",[59,6714,6715],{},"~/.bashrc",[59,6717,6718],{},"~/.zshrc",[126,6720,6722],{"className":128,"code":6721,"language":130,"meta":34,"style":34},"umask 027\n",[59,6723,6724],{"__ignoreMap":34},[134,6725,6726,6728],{"class":136,"line":137},[134,6727,6600],{"class":2622},[134,6729,6730],{"class":150}," 027\n",[11,6732,6733],{},"Then, apply the change with:",[126,6735,6737],{"className":128,"code":6736,"language":130,"meta":34,"style":34},"source ~/.bashrc  # or source ~/.zshrc\n",[59,6738,6739],{"__ignoreMap":34},[134,6740,6741,6744,6747],{"class":136,"line":137},[134,6742,6743],{"class":2622},"source",[134,6745,6746],{"class":143}," ~/.bashrc",[134,6748,6749],{"class":1165},"  # or source ~/.zshrc\n",[115,6751,6753],{"id":6752},"step-4-validate-the-configuration","Step 4: Validate the Configuration",[11,6755,6756],{},"Verify that everything is set correctly:",[126,6758,6760],{"className":128,"code":6759,"language":130,"meta":34,"style":34},"ls -l /var/www\n",[59,6761,6762],{"__ignoreMap":34},[134,6763,6764,6767,6770],{"class":136,"line":137},[134,6765,6766],{"class":140},"ls",[134,6768,6769],{"class":150}," -l",[134,6771,6415],{"class":143},[11,6773,6774],{},"Expect to see:",[64,6776,6777,6792,6803],{},[67,6778,6779,2279,6782,216,6785,6787,6788,6791],{},[56,6780,6781],{},"Directories:",[59,6783,6784],{},"drwxr-s---",[59,6786,6584],{},"), with the ",[59,6789,6790],{},"s"," indicating setgid.",[67,6793,6794,2279,6797,216,6800,734],{},[56,6795,6796],{},"Files:",[59,6798,6799],{},"-rw-r-----",[59,6801,6802],{},"0640",[67,6804,6805,2279,6808,548],{},[56,6806,6807],{},"Ownership:",[59,6809,6810],{},"nico:www-data",[11,6812,6813],{},"If discrepancies appear, reapply the ownership or permissions commands from earlier steps to correct them.",[115,6815,5737],{"id":5736},[11,6817,6818],{},"Today, we've covered all the essentials—understanding permission strings, setting the right ownership for your files, and ensuring your web server can function securely without exposing vulnerabilities.",[11,6820,6821],{},"Server security isn't just a switch you flip; it takes attention and care. So don't skip this step! Take a few minutes to double-check your setup, make any necessary tweaks, and you'll sleep better knowing your server is secure.",[11,6823,3670],{},[1356,6825,6826],{},"html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html pre.shiki code .sYf5A, html code.shiki .sYf5A{--shiki-default:#66D9EF}html pre.shiki code .snpHw, html code.shiki .snpHw{--shiki-default:#88846F}",{"title":34,"searchDepth":35,"depth":35,"links":6828},[6829,6833],{"id":5804,"depth":35,"text":5805,"children":6830},[6831,6832],{"id":5967,"depth":169,"text":5968},{"id":6045,"depth":169,"text":6046},{"id":6280,"depth":35,"text":6281,"children":6834},[6835,6836,6837,6838,6839],{"id":6381,"depth":169,"text":6382},{"id":6428,"depth":169,"text":6429},{"id":6574,"depth":169,"text":6575},{"id":6752,"depth":169,"text":6753},{"id":5736,"depth":169,"text":5737},"2024-10-20","/img/blog/server-permissions.png",{},"/blog/a-step-by-step-guide-to-setting-up-permissions-on-your-linux-web-server",{"title":5786,"description":5791},"blog/a-step-by-step-guide-to-setting-up-permissions-on-your-linux-web-server","lasxkwH8QR4--kn_eCdH0AIcR6aJOSZ5xDFwFWaF-gw",1787771509371]