[{"data":1,"prerenderedAt":7050},["ShallowReactive",2],{"update-\u002Fupdates\u002Fintegrations-rewrite":3,"updates-index-en":1741},{"id":4,"title":5,"body":6,"date":1732,"description":1733,"extension":1734,"meta":1735,"navigation":522,"path":1736,"seo":1737,"stem":1738,"tag":1739,"__hash__":1740},"updates_en\u002Fupdates\u002Fintegrations-rewrite.md","One Interface for All Integrations",{"type":7,"value":8,"toc":1712},"minimark",[9,37,47,50,56,61,66,75,79,82,151,154,158,162,177,387,409,468,472,486,589,598,602,605,609,612,843,855,859,866,1051,1068,1072,1075,1268,1286,1290,1294,1303,1306,1509,1512,1516,1525,1668,1671,1680,1684,1687,1708],[10,11,12],"note",{},[13,14,15,16,21,22,26,27,31,32,36],"p",{},"This post is a look under the hood. It explains how the integrations are built in the server, not what you do with them. What Knecht does is on the ",[17,18,20],"a",{"href":19},"\u002F","home page",", and how it works on GitHub and in Jira is in ",[17,23,25],{"href":24},"\u002Fupdates\u002Fsessions-and-mentions","Knecht Now Replies on GitHub",". Setup is covered in the docs for ",[17,28,30],{"href":29},"\u002Fdocs\u002Fintegrations\u002Fgithub","GitHub"," and ",[17,33,35],{"href":34},"\u002Fdocs\u002Fintegrations\u002Fjira","Jira",".",[13,38,39,40,46],{},"Knecht started with one integration, GitHub. Then Jira came along, and Jira works differently. There are tickets instead of pull requests and ",[17,41,45],{"href":42,"rel":43},"https:\u002F\u002Fsupport.atlassian.com\u002Fjira-cloud-administration\u002Fdocs\u002Fwhat-are-issue-statuses-priorities-and-resolutions\u002F",[44],"nofollow","status categories"," instead of open and closed. A ticket cannot show a pull request, so Knecht writes the result back as a comment. So Jira got its own code. Every further issue tracker would have gotten that code once more. This post tells how we turned it into one shared interface.",[13,48,49],{},"This is what the result looks like in Jira. A ticket gets a label, Knecht picks it up, works in its environment and comes back with a pull request and a comment.",[51,52],"update-youtube",{"caption":53,"id":54,"title":55},"A Jira ticket gets a label, Knecht works and comes back with a PR and a comment","9FFm1AlmRn4","Knecht Works on Jira",[57,58,60],"h2",{"id":59},"the-problem","The Problem",[62,63,65],"h3",{"id":64},"copied-infrastructure","Copied Infrastructure",[13,67,68,69,74],{},"Every tool needs the same four things. Knecht has to store credentials, show the connection in the settings, load labels and statuses for the trigger form and receive webhooks. A ",[17,70,73],{"href":71,"rel":72},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Fwebhooks\u002Fabout-webhooks",[44],"webhook"," is the HTTP request a tool sends to tell Knecht about a change. Jira had its own API routes, its own database table and its own settings panel for this. A second tracker would have copied all of it and only swapped field names and texts.",[62,76,78],{"id":77},"tool-names-in-the-code","Tool Names in the Code",[13,80,81],{},"The bigger issue was that the rest of Knecht knew the tools by name. Wherever an integration mattered, there was a check like this:",[83,84,89],"pre",{"className":85,"code":86,"language":87,"meta":88,"style":88},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","if (tool === 'jira') {\n  await postResultComment(ticket)\n}\n","ts","",[90,91,92,125,145],"code",{"__ignoreMap":88},[93,94,97,101,105,109,112,116,119,122],"span",{"class":95,"line":96},"line",1,[93,98,100],{"class":99},"s7zQu","if",[93,102,104],{"class":103},"sTEyZ"," (tool ",[93,106,108],{"class":107},"sMK4o","===",[93,110,111],{"class":107}," '",[93,113,115],{"class":114},"sfazB","jira",[93,117,118],{"class":107},"'",[93,120,121],{"class":103},") ",[93,123,124],{"class":107},"{\n",[93,126,128,131,135,139,142],{"class":95,"line":127},2,[93,129,130],{"class":99},"  await",[93,132,134],{"class":133},"s2Zo4"," postResultComment",[93,136,138],{"class":137},"swJcz","(",[93,140,141],{"class":103},"ticket",[93,143,144],{"class":137},")\n",[93,146,148],{"class":95,"line":147},3,[93,149,150],{"class":107},"}\n",[13,152,153],{},"The code that finishes a run knew that Jira needs a result comment. Mention handling only knew GitHub. For every new tool we would have had to find and extend all of those places.",[57,155,157],{"id":156},"the-interface","The Interface",[62,159,161],{"id":160},"capabilities-not-names","Capabilities, Not Names",[13,163,164,165,170,171,176],{},"Now an integration is one object that fulfils a fixed ",[17,166,169],{"href":167,"rel":168},"https:\u002F\u002Fwww.typescriptlang.org\u002Fdocs\u002Fhandbook\u002F2\u002Fobjects.html",[44],"TypeScript interface",". The rest of Knecht no longer asks which tool it is looking at, but what the tool can do. If you know your design patterns, this is an ",[17,172,175],{"href":173,"rel":174},"https:\u002F\u002Frefactoring.guru\u002Fdesign-patterns\u002Fadapter",[44],"adapter",". Heavily simplified, the interface looks like this:",[83,178,180],{"className":85,"code":179,"language":87,"meta":88,"style":88},"interface Integration {\n  webhook: {\n    verify(request): boolean\n    parse(request): Delivery\n  }\n  comment(ticket, text): Promise\u003Cvoid>\n  labels?: { list(ticket), apply(ticket, names) }\n  statuses?: { list(ticket), move(ticket, status) }\n  assignee?: { take(ticket), handBack(ticket, person) }\n}\n",[90,181,182,195,205,222,237,243,273,313,347,382],{"__ignoreMap":88},[93,183,184,188,192],{"class":95,"line":96},[93,185,187],{"class":186},"spNyl","interface",[93,189,191],{"class":190},"sBMFI"," Integration",[93,193,194],{"class":107}," {\n",[93,196,197,200,203],{"class":95,"line":127},[93,198,199],{"class":137},"  webhook",[93,201,202],{"class":107},":",[93,204,194],{"class":107},[93,206,207,210,212,216,219],{"class":95,"line":147},[93,208,209],{"class":137},"    verify",[93,211,138],{"class":107},[93,213,215],{"class":214},"sHdIc","request",[93,217,218],{"class":107},"):",[93,220,221],{"class":190}," boolean\n",[93,223,225,228,230,232,234],{"class":95,"line":224},4,[93,226,227],{"class":137},"    parse",[93,229,138],{"class":107},[93,231,215],{"class":214},[93,233,218],{"class":107},[93,235,236],{"class":190}," Delivery\n",[93,238,240],{"class":95,"line":239},5,[93,241,242],{"class":107},"  }\n",[93,244,246,249,251,253,256,259,261,264,267,270],{"class":95,"line":245},6,[93,247,248],{"class":137},"  comment",[93,250,138],{"class":107},[93,252,141],{"class":214},[93,254,255],{"class":107},",",[93,257,258],{"class":214}," text",[93,260,218],{"class":107},[93,262,263],{"class":190}," Promise",[93,265,266],{"class":107},"\u003C",[93,268,269],{"class":190},"void",[93,271,272],{"class":107},">\n",[93,274,276,279,282,285,288,290,292,295,298,300,302,304,307,310],{"class":95,"line":275},7,[93,277,278],{"class":137},"  labels",[93,280,281],{"class":107},"?:",[93,283,284],{"class":107}," {",[93,286,287],{"class":137}," list",[93,289,138],{"class":107},[93,291,141],{"class":214},[93,293,294],{"class":107},"),",[93,296,297],{"class":137}," apply",[93,299,138],{"class":107},[93,301,141],{"class":214},[93,303,255],{"class":107},[93,305,306],{"class":214}," names",[93,308,309],{"class":107},")",[93,311,312],{"class":107}," }\n",[93,314,316,319,321,323,325,327,329,331,334,336,338,340,343,345],{"class":95,"line":315},8,[93,317,318],{"class":137},"  statuses",[93,320,281],{"class":107},[93,322,284],{"class":107},[93,324,287],{"class":137},[93,326,138],{"class":107},[93,328,141],{"class":214},[93,330,294],{"class":107},[93,332,333],{"class":137}," move",[93,335,138],{"class":107},[93,337,141],{"class":214},[93,339,255],{"class":107},[93,341,342],{"class":214}," status",[93,344,309],{"class":107},[93,346,312],{"class":107},[93,348,350,353,355,357,360,362,364,366,369,371,373,375,378,380],{"class":95,"line":349},9,[93,351,352],{"class":137},"  assignee",[93,354,281],{"class":107},[93,356,284],{"class":107},[93,358,359],{"class":137}," take",[93,361,138],{"class":107},[93,363,141],{"class":214},[93,365,294],{"class":107},[93,367,368],{"class":137}," handBack",[93,370,138],{"class":107},[93,372,141],{"class":214},[93,374,255],{"class":107},[93,376,377],{"class":214}," person",[93,379,309],{"class":107},[93,381,312],{"class":107},[93,383,385],{"class":95,"line":384},10,[93,386,150],{"class":107},[13,388,389,392,393,398,399,402,403,408],{},[90,390,391],{},"verify"," checks the ",[17,394,397],{"href":395,"rel":396},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Fwebhooks\u002Fusing-webhooks\u002Fvalidating-webhook-deliveries",[44],"signature"," of a webhook, that is whether it really comes from the tool. ",[90,400,401],{},"parse"," translates it into a shape Knecht understands. Every tool can comment, everything with a question mark is ",[17,404,407],{"href":405,"rel":406},"https:\u002F\u002Fwww.typescriptlang.org\u002Fdocs\u002Fhandbook\u002F2\u002Fobjects.html#optional-properties",[44],"optional",". The check from above becomes:",[83,410,412],{"className":85,"code":411,"language":87,"meta":88,"style":88},"if (integration.labels) {\n  await integration.labels.apply(ticket, ['bug'])\n}\n",[90,413,414,428,464],{"__ignoreMap":88},[93,415,416,418,421,423,426],{"class":95,"line":96},[93,417,100],{"class":99},[93,419,420],{"class":103}," (integration",[93,422,36],{"class":107},[93,424,425],{"class":103},"labels) ",[93,427,124],{"class":107},[93,429,430,432,435,437,440,442,445,447,449,451,454,456,459,461],{"class":95,"line":127},[93,431,130],{"class":99},[93,433,434],{"class":103}," integration",[93,436,36],{"class":107},[93,438,439],{"class":103},"labels",[93,441,36],{"class":107},[93,443,444],{"class":133},"apply",[93,446,138],{"class":137},[93,448,141],{"class":103},[93,450,255],{"class":107},[93,452,453],{"class":137}," [",[93,455,118],{"class":107},[93,457,458],{"class":114},"bug",[93,460,118],{"class":107},[93,462,463],{"class":137},"])\n",[93,465,466],{"class":95,"line":147},[93,467,150],{"class":107},[62,469,471],{"id":470},"one-path-for-every-webhook","One Path for Every Webhook",[13,473,474,476,477,480,481,485],{},[90,475,401],{}," returns the same shape for every tool. A webhook can contain a comment that addresses Knecht directly (a ",[17,478,479],{"href":24},"mention","), a status change on the ticket, or an event a ",[17,482,484],{"href":483},"\u002Fdocs\u002Fusage\u002Ftriggers","trigger"," reacts to. A trigger is the rule that starts a workflow. Often a webhook carries several of these, a closed ticket for example is a status change and maybe a trigger event too. What Knecht does with them lives in exactly one place:",[83,487,489],{"className":85,"code":488,"language":87,"meta":88,"style":88},"const delivery = await integration.webhook.parse(request)\n\nif (delivery.comment) handleMention(delivery.comment)\nif (delivery.statusChange) syncTicketStatus(delivery.statusChange)\nif (delivery.event) startMatchingWorkflows(delivery.event)\n",[90,490,491,518,524,547,568],{"__ignoreMap":88},[93,492,493,496,499,502,505,507,509,511,513,515],{"class":95,"line":96},[93,494,495],{"class":186},"const",[93,497,498],{"class":103}," delivery ",[93,500,501],{"class":107},"=",[93,503,504],{"class":99}," await",[93,506,434],{"class":103},[93,508,36],{"class":107},[93,510,73],{"class":103},[93,512,36],{"class":107},[93,514,401],{"class":133},[93,516,517],{"class":103},"(request)\n",[93,519,520],{"class":95,"line":127},[93,521,523],{"emptyLinePlaceholder":522},true,"\n",[93,525,526,528,531,533,536,539,542,544],{"class":95,"line":147},[93,527,100],{"class":99},[93,529,530],{"class":103}," (delivery",[93,532,36],{"class":107},[93,534,535],{"class":103},"comment) ",[93,537,538],{"class":133},"handleMention",[93,540,541],{"class":103},"(delivery",[93,543,36],{"class":107},[93,545,546],{"class":103},"comment)\n",[93,548,549,551,553,555,558,561,563,565],{"class":95,"line":224},[93,550,100],{"class":99},[93,552,530],{"class":103},[93,554,36],{"class":107},[93,556,557],{"class":103},"statusChange) ",[93,559,560],{"class":133},"syncTicketStatus",[93,562,541],{"class":103},[93,564,36],{"class":107},[93,566,567],{"class":103},"statusChange)\n",[93,569,570,572,574,576,579,582,584,586],{"class":95,"line":239},[93,571,100],{"class":99},[93,573,530],{"class":103},[93,575,36],{"class":107},[93,577,578],{"class":103},"event) ",[93,580,581],{"class":133},"startMatchingWorkflows",[93,583,541],{"class":103},[93,585,36],{"class":107},[93,587,588],{"class":103},"event)\n",[13,590,591,592,597],{},"The integration only has to know how its tool sends data. What follows from it, Knecht decides the same way for every tool. Alexis King describes the idea behind this in ",[17,593,596],{"href":594,"rel":595},"https:\u002F\u002Flexi-lambda.github.io\u002Fblog\u002F2019\u002F11\u002F05\u002Fparse-don-t-validate\u002F",[44],"Parse, don't validate",": foreign data is translated into your own type once at the boundary, and after that the code only works with that type.",[57,599,601],{"id":600},"differences-as-data","Differences as Data",[13,603,604],{},"What still differs between two tools is mostly terms and formats. A ticket has another name, other statuses count as done, the title sits somewhere else in the payload. Each integration describes that as data, and the logic around it exists only once.",[62,606,608],{"id":607},"the-tracker","The Tracker",[13,610,611],{},"A tracker is a tool where a team manages its tickets, like Jira. What Knecht needs to know about it fits into a short description:",[83,613,615],{"className":85,"code":614,"language":87,"meta":88,"style":88},"const jira = {\n  noun: 'ticket',\n  statusGroups: { new: 'To Do', indeterminate: 'In Progress', done: 'Done' },\n  closedGroups: ['done'],\n\n  toTicket: issue => ({\n    title: issue.fields.summary,\n    body: adfToMarkdown(issue.fields.description),\n    status: issue.fields.status.name,\n    labels: issue.fields.labels,\n  }),\n}\n",[90,616,617,628,644,696,717,721,739,760,784,809,828,838],{"__ignoreMap":88},[93,618,619,621,624,626],{"class":95,"line":96},[93,620,495],{"class":186},[93,622,623],{"class":103}," jira ",[93,625,501],{"class":107},[93,627,194],{"class":107},[93,629,630,633,635,637,639,641],{"class":95,"line":127},[93,631,632],{"class":137},"  noun",[93,634,202],{"class":107},[93,636,111],{"class":107},[93,638,141],{"class":114},[93,640,118],{"class":107},[93,642,643],{"class":107},",\n",[93,645,646,649,651,653,656,658,660,663,665,667,670,672,674,677,679,681,684,686,688,691,693],{"class":95,"line":147},[93,647,648],{"class":137},"  statusGroups",[93,650,202],{"class":107},[93,652,284],{"class":107},[93,654,655],{"class":137}," new",[93,657,202],{"class":107},[93,659,111],{"class":107},[93,661,662],{"class":114},"To Do",[93,664,118],{"class":107},[93,666,255],{"class":107},[93,668,669],{"class":137}," indeterminate",[93,671,202],{"class":107},[93,673,111],{"class":107},[93,675,676],{"class":114},"In Progress",[93,678,118],{"class":107},[93,680,255],{"class":107},[93,682,683],{"class":137}," done",[93,685,202],{"class":107},[93,687,111],{"class":107},[93,689,690],{"class":114},"Done",[93,692,118],{"class":107},[93,694,695],{"class":107}," },\n",[93,697,698,701,703,705,707,710,712,715],{"class":95,"line":224},[93,699,700],{"class":137},"  closedGroups",[93,702,202],{"class":107},[93,704,453],{"class":103},[93,706,118],{"class":107},[93,708,709],{"class":114},"done",[93,711,118],{"class":107},[93,713,714],{"class":103},"]",[93,716,643],{"class":107},[93,718,719],{"class":95,"line":239},[93,720,523],{"emptyLinePlaceholder":522},[93,722,723,726,728,731,734,737],{"class":95,"line":245},[93,724,725],{"class":133},"  toTicket",[93,727,202],{"class":107},[93,729,730],{"class":214}," issue",[93,732,733],{"class":186}," =>",[93,735,736],{"class":103}," (",[93,738,124],{"class":107},[93,740,741,744,746,748,750,753,755,758],{"class":95,"line":275},[93,742,743],{"class":137},"    title",[93,745,202],{"class":107},[93,747,730],{"class":103},[93,749,36],{"class":107},[93,751,752],{"class":103},"fields",[93,754,36],{"class":107},[93,756,757],{"class":103},"summary",[93,759,643],{"class":107},[93,761,762,765,767,770,773,775,777,779,782],{"class":95,"line":315},[93,763,764],{"class":137},"    body",[93,766,202],{"class":107},[93,768,769],{"class":133}," adfToMarkdown",[93,771,772],{"class":103},"(issue",[93,774,36],{"class":107},[93,776,752],{"class":103},[93,778,36],{"class":107},[93,780,781],{"class":103},"description)",[93,783,643],{"class":107},[93,785,786,789,791,793,795,797,799,802,804,807],{"class":95,"line":349},[93,787,788],{"class":137},"    status",[93,790,202],{"class":107},[93,792,730],{"class":103},[93,794,36],{"class":107},[93,796,752],{"class":103},[93,798,36],{"class":107},[93,800,801],{"class":103},"status",[93,803,36],{"class":107},[93,805,806],{"class":103},"name",[93,808,643],{"class":107},[93,810,811,814,816,818,820,822,824,826],{"class":95,"line":384},[93,812,813],{"class":137},"    labels",[93,815,202],{"class":107},[93,817,730],{"class":103},[93,819,36],{"class":107},[93,821,752],{"class":103},[93,823,36],{"class":107},[93,825,439],{"class":103},[93,827,643],{"class":107},[93,829,831,834,836],{"class":95,"line":830},11,[93,832,833],{"class":107},"  }",[93,835,309],{"class":103},[93,837,643],{"class":107},[93,839,841],{"class":95,"line":840},12,[93,842,150],{"class":107},[13,844,845,846,849,850,854],{},"The upper part is the tool's vocabulary, that is what a ticket is called, which status categories exist and which of them count as done. ",[90,847,848],{},"toTicket"," translates the webhook data into one uniform ticket. From there the same code decides for every tracker whether a trigger starts, and the text the ",[17,851,853],{"href":852},"\u002Fdocs\u002Fusage\u002Fagent","agent"," reads about the ticket comes from it too.",[62,856,858],{"id":857},"the-trigger-form","The Trigger Form",[13,860,861,862,865],{},"In the ",[17,863,864],{"href":483},"trigger dialog"," you pick which event starts a workflow. Each integration describes the events it offers:",[83,867,869],{"className":85,"code":868,"language":87,"meta":88,"style":88},"trigger: {\n  form: {\n    events: [\n      { label: 'Label added', options: 'labels' },\n      { label: 'Status reached', options: 'statuses' },\n    ],\n  },\n  options: {\n    labels: () => jira.get('\u002Flabel'),\n    statuses: () => jira.get('\u002Fstatus'),\n  },\n}\n",[90,870,871,879,888,898,930,960,967,972,981,1013,1043,1047],{"__ignoreMap":88},[93,872,873,875,877],{"class":95,"line":96},[93,874,484],{"class":190},[93,876,202],{"class":107},[93,878,194],{"class":107},[93,880,881,884,886],{"class":95,"line":127},[93,882,883],{"class":190},"  form",[93,885,202],{"class":107},[93,887,194],{"class":107},[93,889,890,893,895],{"class":95,"line":147},[93,891,892],{"class":190},"    events",[93,894,202],{"class":107},[93,896,897],{"class":137}," [\n",[93,899,900,903,906,908,910,913,915,917,920,922,924,926,928],{"class":95,"line":224},[93,901,902],{"class":107},"      {",[93,904,905],{"class":137}," label",[93,907,202],{"class":107},[93,909,111],{"class":107},[93,911,912],{"class":114},"Label added",[93,914,118],{"class":107},[93,916,255],{"class":107},[93,918,919],{"class":137}," options",[93,921,202],{"class":107},[93,923,111],{"class":107},[93,925,439],{"class":114},[93,927,118],{"class":107},[93,929,695],{"class":107},[93,931,932,934,936,938,940,943,945,947,949,951,953,956,958],{"class":95,"line":239},[93,933,902],{"class":107},[93,935,905],{"class":137},[93,937,202],{"class":107},[93,939,111],{"class":107},[93,941,942],{"class":114},"Status reached",[93,944,118],{"class":107},[93,946,255],{"class":107},[93,948,919],{"class":137},[93,950,202],{"class":107},[93,952,111],{"class":107},[93,954,955],{"class":114},"statuses",[93,957,118],{"class":107},[93,959,695],{"class":107},[93,961,962,965],{"class":95,"line":245},[93,963,964],{"class":137},"    ]",[93,966,643],{"class":107},[93,968,969],{"class":95,"line":275},[93,970,971],{"class":107},"  },\n",[93,973,974,977,979],{"class":95,"line":315},[93,975,976],{"class":190},"  options",[93,978,202],{"class":107},[93,980,194],{"class":107},[93,982,983,985,987,990,992,995,997,1000,1002,1004,1007,1009,1011],{"class":95,"line":349},[93,984,813],{"class":190},[93,986,202],{"class":107},[93,988,989],{"class":107}," ()",[93,991,733],{"class":186},[93,993,994],{"class":103}," jira",[93,996,36],{"class":107},[93,998,999],{"class":133},"get",[93,1001,138],{"class":137},[93,1003,118],{"class":107},[93,1005,1006],{"class":114},"\u002Flabel",[93,1008,118],{"class":107},[93,1010,309],{"class":137},[93,1012,643],{"class":107},[93,1014,1015,1018,1020,1022,1024,1026,1028,1030,1032,1034,1037,1039,1041],{"class":95,"line":384},[93,1016,1017],{"class":190},"    statuses",[93,1019,202],{"class":107},[93,1021,989],{"class":107},[93,1023,733],{"class":186},[93,1025,994],{"class":103},[93,1027,36],{"class":107},[93,1029,999],{"class":133},[93,1031,138],{"class":137},[93,1033,118],{"class":107},[93,1035,1036],{"class":114},"\u002Fstatus",[93,1038,118],{"class":107},[93,1040,309],{"class":137},[93,1042,643],{"class":107},[93,1044,1045],{"class":95,"line":830},[93,1046,971],{"class":107},[93,1048,1049],{"class":95,"line":840},[93,1050,150],{"class":107},[13,1052,1053,1054,1057,1058,1061,1062,1064,1065,1067],{},"From ",[90,1055,1056],{},"form"," Knecht builds the dialog. ",[90,1059,1060],{},"options"," fills the dropdowns with the labels and statuses that actually exist in Jira. ",[90,1063,1056],{}," is plain data and goes to the browser, ",[90,1066,1060],{}," runs on the server, because only the server has the credentials.",[62,1069,1071],{"id":1070},"the-connection","The Connection",[13,1073,1074],{},"The connection is the set of credentials Knecht uses to read and write in the tool, for Jira the site URL, an email and an API token. An admin enters them once in the settings, and from then on the connection applies to the whole instance. This form is a description too. The integration only says which fields it needs and how it checks that the credentials work:",[83,1076,1078],{"className":85,"code":1077,"language":87,"meta":88,"style":88},"connection: {\n  fields: [\n    { key: 'siteUrl', label: 'Site URL', type: 'url' },\n    { key: 'email', label: 'Email', type: 'email' },\n    { key: 'apiToken', label: 'API token', type: 'secret' },\n  ],\n  verify: values => jira.whoAmI(values),\n}\n",[90,1079,1080,1089,1098,1144,1186,1229,1236,1264],{"__ignoreMap":88},[93,1081,1082,1085,1087],{"class":95,"line":96},[93,1083,1084],{"class":190},"connection",[93,1086,202],{"class":107},[93,1088,194],{"class":107},[93,1090,1091,1094,1096],{"class":95,"line":127},[93,1092,1093],{"class":190},"  fields",[93,1095,202],{"class":107},[93,1097,897],{"class":137},[93,1099,1100,1103,1106,1108,1110,1113,1115,1117,1119,1121,1123,1126,1128,1130,1133,1135,1137,1140,1142],{"class":95,"line":147},[93,1101,1102],{"class":107},"    {",[93,1104,1105],{"class":137}," key",[93,1107,202],{"class":107},[93,1109,111],{"class":107},[93,1111,1112],{"class":114},"siteUrl",[93,1114,118],{"class":107},[93,1116,255],{"class":107},[93,1118,905],{"class":137},[93,1120,202],{"class":107},[93,1122,111],{"class":107},[93,1124,1125],{"class":114},"Site URL",[93,1127,118],{"class":107},[93,1129,255],{"class":107},[93,1131,1132],{"class":137}," type",[93,1134,202],{"class":107},[93,1136,111],{"class":107},[93,1138,1139],{"class":114},"url",[93,1141,118],{"class":107},[93,1143,695],{"class":107},[93,1145,1146,1148,1150,1152,1154,1157,1159,1161,1163,1165,1167,1170,1172,1174,1176,1178,1180,1182,1184],{"class":95,"line":224},[93,1147,1102],{"class":107},[93,1149,1105],{"class":137},[93,1151,202],{"class":107},[93,1153,111],{"class":107},[93,1155,1156],{"class":114},"email",[93,1158,118],{"class":107},[93,1160,255],{"class":107},[93,1162,905],{"class":137},[93,1164,202],{"class":107},[93,1166,111],{"class":107},[93,1168,1169],{"class":114},"Email",[93,1171,118],{"class":107},[93,1173,255],{"class":107},[93,1175,1132],{"class":137},[93,1177,202],{"class":107},[93,1179,111],{"class":107},[93,1181,1156],{"class":114},[93,1183,118],{"class":107},[93,1185,695],{"class":107},[93,1187,1188,1190,1192,1194,1196,1199,1201,1203,1205,1207,1209,1212,1214,1216,1218,1220,1222,1225,1227],{"class":95,"line":239},[93,1189,1102],{"class":107},[93,1191,1105],{"class":137},[93,1193,202],{"class":107},[93,1195,111],{"class":107},[93,1197,1198],{"class":114},"apiToken",[93,1200,118],{"class":107},[93,1202,255],{"class":107},[93,1204,905],{"class":137},[93,1206,202],{"class":107},[93,1208,111],{"class":107},[93,1210,1211],{"class":114},"API token",[93,1213,118],{"class":107},[93,1215,255],{"class":107},[93,1217,1132],{"class":137},[93,1219,202],{"class":107},[93,1221,111],{"class":107},[93,1223,1224],{"class":114},"secret",[93,1226,118],{"class":107},[93,1228,695],{"class":107},[93,1230,1231,1234],{"class":95,"line":245},[93,1232,1233],{"class":137},"  ]",[93,1235,643],{"class":107},[93,1237,1238,1241,1243,1246,1248,1250,1252,1255,1257,1260,1262],{"class":95,"line":275},[93,1239,1240],{"class":190},"  verify",[93,1242,202],{"class":107},[93,1244,1245],{"class":214}," values",[93,1247,733],{"class":186},[93,1249,994],{"class":103},[93,1251,36],{"class":107},[93,1253,1254],{"class":133},"whoAmI",[93,1256,138],{"class":137},[93,1258,1259],{"class":103},"values",[93,1261,309],{"class":137},[93,1263,643],{"class":107},[93,1265,1266],{"class":95,"line":315},[93,1267,150],{"class":107},[13,1269,1270,1271,1273,1274,1276,1277,1282,1283,1285],{},"One shared Vue component builds the form from it, and one shared API route saves it. The field type brings its rules. A ",[90,1272,1139],{}," has to start with https, and a ",[90,1275,1224],{}," is ",[17,1278,1281],{"href":1279,"rel":1280},"https:\u002F\u002Fcheatsheetseries.owasp.org\u002Fcheatsheets\u002FCryptographic_Storage_Cheat_Sheet.html",[44],"stored encrypted"," and only shown as a preview. Before saving, ",[90,1284,391],{}," signs in to the tool once, so wrong credentials never get stored.",[57,1287,1289],{"id":1288},"tests-against-the-interface","Tests Against the Interface",[62,1291,1293],{"id":1292},"from-clicking-to-fixtures","From Clicking to Fixtures",[13,1295,1296,1297,1302],{},"At first we tested triggers by hand. The local Knecht instance was reachable through a ",[17,1298,1301],{"href":1299,"rel":1300},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fcloudflare-one\u002Fnetworks\u002Fconnectors\u002Fcloudflare-tunnel\u002F",[44],"Cloudflare Tunnel",", we created a ticket in Jira, set a label, moved the status and checked the log to see whether the right workflow started. That took a while, and after every change to the trigger logic it started over.",[13,1304,1305],{},"So Knecht can now write every verified webhook to a folder as a JSON file. One round of clicking through the tool is enough, and the recorded webhooks become fixtures. As of September 2026 there are 89 of them. The tests replay them and state for each trigger which webhooks it has to fire on and which it must not:",[83,1307,1309],{"className":85,"code":1308,"language":87,"meta":88,"style":88},"const cases = [\n  { trigger: 'Label \"bug\" added', firesOn: ['ticket-labeled-bug'] },\n  { trigger: 'Status \"Done\" reached', firesOn: ['ticket-moved-to-done'] },\n]\n\nfor (const { trigger, firesOn } of cases) {\n  for (const webhook of recordedWebhooks) {\n    expect(fires(trigger, webhook)).toBe(firesOn.includes(webhook.name))\n  }\n}\n",[90,1310,1311,1322,1361,1395,1400,1404,1433,1454,1501,1505],{"__ignoreMap":88},[93,1312,1313,1315,1318,1320],{"class":95,"line":96},[93,1314,495],{"class":186},[93,1316,1317],{"class":103}," cases ",[93,1319,501],{"class":107},[93,1321,897],{"class":103},[93,1323,1324,1327,1330,1332,1334,1337,1339,1341,1344,1346,1348,1350,1353,1355,1358],{"class":95,"line":127},[93,1325,1326],{"class":107},"  {",[93,1328,1329],{"class":137}," trigger",[93,1331,202],{"class":107},[93,1333,111],{"class":107},[93,1335,1336],{"class":114},"Label \"bug\" added",[93,1338,118],{"class":107},[93,1340,255],{"class":107},[93,1342,1343],{"class":137}," firesOn",[93,1345,202],{"class":107},[93,1347,453],{"class":103},[93,1349,118],{"class":107},[93,1351,1352],{"class":114},"ticket-labeled-bug",[93,1354,118],{"class":107},[93,1356,1357],{"class":103},"] ",[93,1359,1360],{"class":107},"},\n",[93,1362,1363,1365,1367,1369,1371,1374,1376,1378,1380,1382,1384,1386,1389,1391,1393],{"class":95,"line":147},[93,1364,1326],{"class":107},[93,1366,1329],{"class":137},[93,1368,202],{"class":107},[93,1370,111],{"class":107},[93,1372,1373],{"class":114},"Status \"Done\" reached",[93,1375,118],{"class":107},[93,1377,255],{"class":107},[93,1379,1343],{"class":137},[93,1381,202],{"class":107},[93,1383,453],{"class":103},[93,1385,118],{"class":107},[93,1387,1388],{"class":114},"ticket-moved-to-done",[93,1390,118],{"class":107},[93,1392,1357],{"class":103},[93,1394,1360],{"class":107},[93,1396,1397],{"class":95,"line":224},[93,1398,1399],{"class":103},"]\n",[93,1401,1402],{"class":95,"line":239},[93,1403,523],{"emptyLinePlaceholder":522},[93,1405,1406,1409,1411,1413,1415,1417,1419,1422,1425,1428,1431],{"class":95,"line":245},[93,1407,1408],{"class":99},"for",[93,1410,736],{"class":103},[93,1412,495],{"class":186},[93,1414,284],{"class":107},[93,1416,1329],{"class":103},[93,1418,255],{"class":107},[93,1420,1421],{"class":103}," firesOn ",[93,1423,1424],{"class":107},"}",[93,1426,1427],{"class":107}," of",[93,1429,1430],{"class":103}," cases) ",[93,1432,124],{"class":107},[93,1434,1435,1438,1440,1442,1445,1447,1450,1452],{"class":95,"line":275},[93,1436,1437],{"class":99},"  for",[93,1439,736],{"class":137},[93,1441,495],{"class":186},[93,1443,1444],{"class":103}," webhook",[93,1446,1427],{"class":107},[93,1448,1449],{"class":103}," recordedWebhooks",[93,1451,121],{"class":137},[93,1453,124],{"class":107},[93,1455,1456,1459,1461,1464,1466,1468,1470,1472,1475,1477,1480,1482,1485,1487,1490,1492,1494,1496,1498],{"class":95,"line":315},[93,1457,1458],{"class":133},"    expect",[93,1460,138],{"class":137},[93,1462,1463],{"class":133},"fires",[93,1465,138],{"class":137},[93,1467,484],{"class":103},[93,1469,255],{"class":107},[93,1471,1444],{"class":103},[93,1473,1474],{"class":137},"))",[93,1476,36],{"class":107},[93,1478,1479],{"class":133},"toBe",[93,1481,138],{"class":137},[93,1483,1484],{"class":103},"firesOn",[93,1486,36],{"class":107},[93,1488,1489],{"class":133},"includes",[93,1491,138],{"class":137},[93,1493,73],{"class":103},[93,1495,36],{"class":107},[93,1497,806],{"class":103},[93,1499,1500],{"class":137},"))\n",[93,1502,1503],{"class":95,"line":349},[93,1504,242],{"class":107},[93,1506,1507],{"class":95,"line":384},[93,1508,150],{"class":107},[13,1510,1511],{},"Once the connection to a tool works and one round of webhooks is recorded, we know within seconds after every change whether the triggers still hold, without opening the tool. If a tool changes its format, we record again and the tests show right away what changed.",[62,1513,1515],{"id":1514},"one-suite-for-all","One Suite for All",[13,1517,1518,1519,1524],{},"The fixtures check whether triggers decide correctly. Whether an integration fulfils the interface at all is checked on a second level. Because all integrations share the same interface, they run through the same test suite in ",[17,1520,1523],{"href":1521,"rel":1522},"https:\u002F\u002Fvitest.dev\u002Fguide\u002F",[44],"Vitest",". An integration only provides building blocks that create webhooks in its format:",[83,1526,1528],{"className":85,"code":1527,"language":87,"meta":88,"style":88},"webhookSuite({\n  integration: jira,\n  created: project => jiraWebhook('issue_created', project),\n  labeled: project => jiraWebhook('issue_updated', project, { labels: ['bug'] }),\n  mention: project => jiraComment(project, '@Knecht please take a look'),\n})\n",[90,1529,1530,1539,1550,1581,1632,1662],{"__ignoreMap":88},[93,1531,1532,1535,1537],{"class":95,"line":96},[93,1533,1534],{"class":133},"webhookSuite",[93,1536,138],{"class":103},[93,1538,124],{"class":107},[93,1540,1541,1544,1546,1548],{"class":95,"line":127},[93,1542,1543],{"class":137},"  integration",[93,1545,202],{"class":107},[93,1547,994],{"class":103},[93,1549,643],{"class":107},[93,1551,1552,1555,1557,1560,1562,1565,1567,1569,1572,1574,1576,1579],{"class":95,"line":147},[93,1553,1554],{"class":133},"  created",[93,1556,202],{"class":107},[93,1558,1559],{"class":214}," project",[93,1561,733],{"class":186},[93,1563,1564],{"class":133}," jiraWebhook",[93,1566,138],{"class":103},[93,1568,118],{"class":107},[93,1570,1571],{"class":114},"issue_created",[93,1573,118],{"class":107},[93,1575,255],{"class":107},[93,1577,1578],{"class":103}," project)",[93,1580,643],{"class":107},[93,1582,1583,1586,1588,1590,1592,1594,1596,1598,1601,1603,1605,1607,1609,1611,1614,1616,1618,1620,1622,1624,1626,1628,1630],{"class":95,"line":224},[93,1584,1585],{"class":133},"  labeled",[93,1587,202],{"class":107},[93,1589,1559],{"class":214},[93,1591,733],{"class":186},[93,1593,1564],{"class":133},[93,1595,138],{"class":103},[93,1597,118],{"class":107},[93,1599,1600],{"class":114},"issue_updated",[93,1602,118],{"class":107},[93,1604,255],{"class":107},[93,1606,1559],{"class":103},[93,1608,255],{"class":107},[93,1610,284],{"class":107},[93,1612,1613],{"class":137}," labels",[93,1615,202],{"class":107},[93,1617,453],{"class":103},[93,1619,118],{"class":107},[93,1621,458],{"class":114},[93,1623,118],{"class":107},[93,1625,1357],{"class":103},[93,1627,1424],{"class":107},[93,1629,309],{"class":103},[93,1631,643],{"class":107},[93,1633,1634,1637,1639,1641,1643,1646,1649,1651,1653,1656,1658,1660],{"class":95,"line":239},[93,1635,1636],{"class":133},"  mention",[93,1638,202],{"class":107},[93,1640,1559],{"class":214},[93,1642,733],{"class":186},[93,1644,1645],{"class":133}," jiraComment",[93,1647,1648],{"class":103},"(project",[93,1650,255],{"class":107},[93,1652,111],{"class":107},[93,1654,1655],{"class":114},"@Knecht please take a look",[93,1657,118],{"class":107},[93,1659,309],{"class":103},[93,1661,643],{"class":107},[93,1663,1664,1666],{"class":95,"line":245},[93,1665,1424],{"class":107},[93,1667,144],{"class":103},[13,1669,1670],{},"The suite sends these webhooks through the real route and expects the same behaviour from every integration.",[13,1672,1673,1674,1679],{},"Martin Fowler calls this a ",[17,1675,1678],{"href":1676,"rel":1677},"https:\u002F\u002Fmartinfowler.com\u002Fbliki\u002FContractTest.html",[44],"contract test",". For a new integration it means we do not have to think about what to test. As soon as it provides the building blocks and the suite is green, it behaves like the others.",[57,1681,1683],{"id":1682},"what-we-take-away","What We Take Away",[13,1685,1686],{},"A new integration now consists of the tracker description, the API calls and the connection form. Triggers, mentions, comments, labels, statuses and the settings panel come with the interface. If you build something like this yourself, four points can guide you:",[1688,1689,1690,1694,1702,1705],"ul",{},[1691,1692,1693],"li",{},"The rest of the app asks for capabilities, never for the name of the tool.",[1691,1695,1696,1697,36],{},"Incoming data is translated into one shared shape at the boundary. After that there is no tool-specific code. Microsoft describes this pattern as an ",[17,1698,1701],{"href":1699,"rel":1700},"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fazure\u002Farchitecture\u002Fpatterns\u002Fanti-corruption-layer",[44],"anti-corruption layer",[1691,1703,1704],{},"What only differs in data, like forms or terms, is described as data and not as code.",[1691,1706,1707],{},"Tests run against the interface, so every integration has to pass the same tests.",[1709,1710,1711],"style",{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}",{"title":88,"searchDepth":127,"depth":127,"links":1713},[1714,1718,1722,1727,1731],{"id":59,"depth":127,"text":60,"children":1715},[1716,1717],{"id":64,"depth":147,"text":65},{"id":77,"depth":147,"text":78},{"id":156,"depth":127,"text":157,"children":1719},[1720,1721],{"id":160,"depth":147,"text":161},{"id":470,"depth":147,"text":471},{"id":600,"depth":127,"text":601,"children":1723},[1724,1725,1726],{"id":607,"depth":147,"text":608},{"id":857,"depth":147,"text":858},{"id":1070,"depth":147,"text":1071},{"id":1288,"depth":127,"text":1289,"children":1728},[1729,1730],{"id":1292,"depth":147,"text":1293},{"id":1514,"depth":147,"text":1515},{"id":1682,"depth":127,"text":1683},"2026-09-24","How we put Knecht's integrations behind one shared interface, so a new tool only brings what is actually different about it.","md",{},"\u002Fupdates\u002Fintegrations-rewrite",{"title":5,"description":1733},"updates\u002Fintegrations-rewrite","Architecture","UhSDNvwKqJKp3PnsNW8bZ_VWidMRK8YP92tFlXwmxgs",[1742,3111,4012,4263,4644,4767,5015,5290,5449,5741,5950,6202,6485,6776,6858,6935],{"id":4,"title":5,"body":1743,"date":1732,"description":1733,"extension":1734,"meta":3109,"navigation":522,"path":1736,"seo":3110,"stem":1738,"tag":1739,"__hash__":1740},{"type":7,"value":1744,"toc":3089},[1745,1757,1762,1764,1766,1768,1770,1775,1777,1779,1817,1819,1821,1823,1831,1995,2007,2057,2059,2067,2151,2156,2158,2160,2162,2164,2362,2368,2370,2374,2540,2550,2552,2554,2728,2739,2741,2743,2748,2750,2926,2928,2930,2935,3063,3065,3070,3072,3074,3087],[10,1746,1747],{},[13,1748,15,1749,21,1751,26,1753,31,1755,36],{},[17,1750,20],{"href":19},[17,1752,25],{"href":24},[17,1754,30],{"href":29},[17,1756,35],{"href":34},[13,1758,39,1759,46],{},[17,1760,45],{"href":42,"rel":1761},[44],[13,1763,49],{},[51,1765],{"caption":53,"id":54,"title":55},[57,1767,60],{"id":59},[62,1769,65],{"id":64},[13,1771,68,1772,74],{},[17,1773,73],{"href":71,"rel":1774},[44],[62,1776,78],{"id":77},[13,1778,81],{},[83,1780,1781],{"className":85,"code":86,"language":87,"meta":88,"style":88},[90,1782,1783,1801,1813],{"__ignoreMap":88},[93,1784,1785,1787,1789,1791,1793,1795,1797,1799],{"class":95,"line":96},[93,1786,100],{"class":99},[93,1788,104],{"class":103},[93,1790,108],{"class":107},[93,1792,111],{"class":107},[93,1794,115],{"class":114},[93,1796,118],{"class":107},[93,1798,121],{"class":103},[93,1800,124],{"class":107},[93,1802,1803,1805,1807,1809,1811],{"class":95,"line":127},[93,1804,130],{"class":99},[93,1806,134],{"class":133},[93,1808,138],{"class":137},[93,1810,141],{"class":103},[93,1812,144],{"class":137},[93,1814,1815],{"class":95,"line":147},[93,1816,150],{"class":107},[13,1818,153],{},[57,1820,157],{"id":156},[62,1822,161],{"id":160},[13,1824,164,1825,170,1828,176],{},[17,1826,169],{"href":167,"rel":1827},[44],[17,1829,175],{"href":173,"rel":1830},[44],[83,1832,1833],{"className":85,"code":179,"language":87,"meta":88,"style":88},[90,1834,1835,1843,1851,1863,1875,1879,1901,1931,1961,1991],{"__ignoreMap":88},[93,1836,1837,1839,1841],{"class":95,"line":96},[93,1838,187],{"class":186},[93,1840,191],{"class":190},[93,1842,194],{"class":107},[93,1844,1845,1847,1849],{"class":95,"line":127},[93,1846,199],{"class":137},[93,1848,202],{"class":107},[93,1850,194],{"class":107},[93,1852,1853,1855,1857,1859,1861],{"class":95,"line":147},[93,1854,209],{"class":137},[93,1856,138],{"class":107},[93,1858,215],{"class":214},[93,1860,218],{"class":107},[93,1862,221],{"class":190},[93,1864,1865,1867,1869,1871,1873],{"class":95,"line":224},[93,1866,227],{"class":137},[93,1868,138],{"class":107},[93,1870,215],{"class":214},[93,1872,218],{"class":107},[93,1874,236],{"class":190},[93,1876,1877],{"class":95,"line":239},[93,1878,242],{"class":107},[93,1880,1881,1883,1885,1887,1889,1891,1893,1895,1897,1899],{"class":95,"line":245},[93,1882,248],{"class":137},[93,1884,138],{"class":107},[93,1886,141],{"class":214},[93,1888,255],{"class":107},[93,1890,258],{"class":214},[93,1892,218],{"class":107},[93,1894,263],{"class":190},[93,1896,266],{"class":107},[93,1898,269],{"class":190},[93,1900,272],{"class":107},[93,1902,1903,1905,1907,1909,1911,1913,1915,1917,1919,1921,1923,1925,1927,1929],{"class":95,"line":275},[93,1904,278],{"class":137},[93,1906,281],{"class":107},[93,1908,284],{"class":107},[93,1910,287],{"class":137},[93,1912,138],{"class":107},[93,1914,141],{"class":214},[93,1916,294],{"class":107},[93,1918,297],{"class":137},[93,1920,138],{"class":107},[93,1922,141],{"class":214},[93,1924,255],{"class":107},[93,1926,306],{"class":214},[93,1928,309],{"class":107},[93,1930,312],{"class":107},[93,1932,1933,1935,1937,1939,1941,1943,1945,1947,1949,1951,1953,1955,1957,1959],{"class":95,"line":315},[93,1934,318],{"class":137},[93,1936,281],{"class":107},[93,1938,284],{"class":107},[93,1940,287],{"class":137},[93,1942,138],{"class":107},[93,1944,141],{"class":214},[93,1946,294],{"class":107},[93,1948,333],{"class":137},[93,1950,138],{"class":107},[93,1952,141],{"class":214},[93,1954,255],{"class":107},[93,1956,342],{"class":214},[93,1958,309],{"class":107},[93,1960,312],{"class":107},[93,1962,1963,1965,1967,1969,1971,1973,1975,1977,1979,1981,1983,1985,1987,1989],{"class":95,"line":349},[93,1964,352],{"class":137},[93,1966,281],{"class":107},[93,1968,284],{"class":107},[93,1970,359],{"class":137},[93,1972,138],{"class":107},[93,1974,141],{"class":214},[93,1976,294],{"class":107},[93,1978,368],{"class":137},[93,1980,138],{"class":107},[93,1982,141],{"class":214},[93,1984,255],{"class":107},[93,1986,377],{"class":214},[93,1988,309],{"class":107},[93,1990,312],{"class":107},[93,1992,1993],{"class":95,"line":384},[93,1994,150],{"class":107},[13,1996,1997,392,1999,398,2002,402,2004,408],{},[90,1998,391],{},[17,2000,397],{"href":395,"rel":2001},[44],[90,2003,401],{},[17,2005,407],{"href":405,"rel":2006},[44],[83,2008,2009],{"className":85,"code":411,"language":87,"meta":88,"style":88},[90,2010,2011,2023,2053],{"__ignoreMap":88},[93,2012,2013,2015,2017,2019,2021],{"class":95,"line":96},[93,2014,100],{"class":99},[93,2016,420],{"class":103},[93,2018,36],{"class":107},[93,2020,425],{"class":103},[93,2022,124],{"class":107},[93,2024,2025,2027,2029,2031,2033,2035,2037,2039,2041,2043,2045,2047,2049,2051],{"class":95,"line":127},[93,2026,130],{"class":99},[93,2028,434],{"class":103},[93,2030,36],{"class":107},[93,2032,439],{"class":103},[93,2034,36],{"class":107},[93,2036,444],{"class":133},[93,2038,138],{"class":137},[93,2040,141],{"class":103},[93,2042,255],{"class":107},[93,2044,453],{"class":137},[93,2046,118],{"class":107},[93,2048,458],{"class":114},[93,2050,118],{"class":107},[93,2052,463],{"class":137},[93,2054,2055],{"class":95,"line":147},[93,2056,150],{"class":107},[62,2058,471],{"id":470},[13,2060,2061,476,2063,480,2065,485],{},[90,2062,401],{},[17,2064,479],{"href":24},[17,2066,484],{"href":483},[83,2068,2069],{"className":85,"code":488,"language":87,"meta":88,"style":88},[90,2070,2071,2093,2097,2115,2133],{"__ignoreMap":88},[93,2072,2073,2075,2077,2079,2081,2083,2085,2087,2089,2091],{"class":95,"line":96},[93,2074,495],{"class":186},[93,2076,498],{"class":103},[93,2078,501],{"class":107},[93,2080,504],{"class":99},[93,2082,434],{"class":103},[93,2084,36],{"class":107},[93,2086,73],{"class":103},[93,2088,36],{"class":107},[93,2090,401],{"class":133},[93,2092,517],{"class":103},[93,2094,2095],{"class":95,"line":127},[93,2096,523],{"emptyLinePlaceholder":522},[93,2098,2099,2101,2103,2105,2107,2109,2111,2113],{"class":95,"line":147},[93,2100,100],{"class":99},[93,2102,530],{"class":103},[93,2104,36],{"class":107},[93,2106,535],{"class":103},[93,2108,538],{"class":133},[93,2110,541],{"class":103},[93,2112,36],{"class":107},[93,2114,546],{"class":103},[93,2116,2117,2119,2121,2123,2125,2127,2129,2131],{"class":95,"line":224},[93,2118,100],{"class":99},[93,2120,530],{"class":103},[93,2122,36],{"class":107},[93,2124,557],{"class":103},[93,2126,560],{"class":133},[93,2128,541],{"class":103},[93,2130,36],{"class":107},[93,2132,567],{"class":103},[93,2134,2135,2137,2139,2141,2143,2145,2147,2149],{"class":95,"line":239},[93,2136,100],{"class":99},[93,2138,530],{"class":103},[93,2140,36],{"class":107},[93,2142,578],{"class":103},[93,2144,581],{"class":133},[93,2146,541],{"class":103},[93,2148,36],{"class":107},[93,2150,588],{"class":103},[13,2152,591,2153,597],{},[17,2154,596],{"href":594,"rel":2155},[44],[57,2157,601],{"id":600},[13,2159,604],{},[62,2161,608],{"id":607},[13,2163,611],{},[83,2165,2166],{"className":85,"code":614,"language":87,"meta":88,"style":88},[90,2167,2168,2178,2192,2236,2254,2258,2272,2290,2310,2332,2350,2358],{"__ignoreMap":88},[93,2169,2170,2172,2174,2176],{"class":95,"line":96},[93,2171,495],{"class":186},[93,2173,623],{"class":103},[93,2175,501],{"class":107},[93,2177,194],{"class":107},[93,2179,2180,2182,2184,2186,2188,2190],{"class":95,"line":127},[93,2181,632],{"class":137},[93,2183,202],{"class":107},[93,2185,111],{"class":107},[93,2187,141],{"class":114},[93,2189,118],{"class":107},[93,2191,643],{"class":107},[93,2193,2194,2196,2198,2200,2202,2204,2206,2208,2210,2212,2214,2216,2218,2220,2222,2224,2226,2228,2230,2232,2234],{"class":95,"line":147},[93,2195,648],{"class":137},[93,2197,202],{"class":107},[93,2199,284],{"class":107},[93,2201,655],{"class":137},[93,2203,202],{"class":107},[93,2205,111],{"class":107},[93,2207,662],{"class":114},[93,2209,118],{"class":107},[93,2211,255],{"class":107},[93,2213,669],{"class":137},[93,2215,202],{"class":107},[93,2217,111],{"class":107},[93,2219,676],{"class":114},[93,2221,118],{"class":107},[93,2223,255],{"class":107},[93,2225,683],{"class":137},[93,2227,202],{"class":107},[93,2229,111],{"class":107},[93,2231,690],{"class":114},[93,2233,118],{"class":107},[93,2235,695],{"class":107},[93,2237,2238,2240,2242,2244,2246,2248,2250,2252],{"class":95,"line":224},[93,2239,700],{"class":137},[93,2241,202],{"class":107},[93,2243,453],{"class":103},[93,2245,118],{"class":107},[93,2247,709],{"class":114},[93,2249,118],{"class":107},[93,2251,714],{"class":103},[93,2253,643],{"class":107},[93,2255,2256],{"class":95,"line":239},[93,2257,523],{"emptyLinePlaceholder":522},[93,2259,2260,2262,2264,2266,2268,2270],{"class":95,"line":245},[93,2261,725],{"class":133},[93,2263,202],{"class":107},[93,2265,730],{"class":214},[93,2267,733],{"class":186},[93,2269,736],{"class":103},[93,2271,124],{"class":107},[93,2273,2274,2276,2278,2280,2282,2284,2286,2288],{"class":95,"line":275},[93,2275,743],{"class":137},[93,2277,202],{"class":107},[93,2279,730],{"class":103},[93,2281,36],{"class":107},[93,2283,752],{"class":103},[93,2285,36],{"class":107},[93,2287,757],{"class":103},[93,2289,643],{"class":107},[93,2291,2292,2294,2296,2298,2300,2302,2304,2306,2308],{"class":95,"line":315},[93,2293,764],{"class":137},[93,2295,202],{"class":107},[93,2297,769],{"class":133},[93,2299,772],{"class":103},[93,2301,36],{"class":107},[93,2303,752],{"class":103},[93,2305,36],{"class":107},[93,2307,781],{"class":103},[93,2309,643],{"class":107},[93,2311,2312,2314,2316,2318,2320,2322,2324,2326,2328,2330],{"class":95,"line":349},[93,2313,788],{"class":137},[93,2315,202],{"class":107},[93,2317,730],{"class":103},[93,2319,36],{"class":107},[93,2321,752],{"class":103},[93,2323,36],{"class":107},[93,2325,801],{"class":103},[93,2327,36],{"class":107},[93,2329,806],{"class":103},[93,2331,643],{"class":107},[93,2333,2334,2336,2338,2340,2342,2344,2346,2348],{"class":95,"line":384},[93,2335,813],{"class":137},[93,2337,202],{"class":107},[93,2339,730],{"class":103},[93,2341,36],{"class":107},[93,2343,752],{"class":103},[93,2345,36],{"class":107},[93,2347,439],{"class":103},[93,2349,643],{"class":107},[93,2351,2352,2354,2356],{"class":95,"line":830},[93,2353,833],{"class":107},[93,2355,309],{"class":103},[93,2357,643],{"class":107},[93,2359,2360],{"class":95,"line":840},[93,2361,150],{"class":107},[13,2363,845,2364,849,2366,854],{},[90,2365,848],{},[17,2367,853],{"href":852},[62,2369,858],{"id":857},[13,2371,861,2372,865],{},[17,2373,864],{"href":483},[83,2375,2376],{"className":85,"code":868,"language":87,"meta":88,"style":88},[90,2377,2378,2386,2394,2402,2430,2458,2464,2468,2476,2504,2532,2536],{"__ignoreMap":88},[93,2379,2380,2382,2384],{"class":95,"line":96},[93,2381,484],{"class":190},[93,2383,202],{"class":107},[93,2385,194],{"class":107},[93,2387,2388,2390,2392],{"class":95,"line":127},[93,2389,883],{"class":190},[93,2391,202],{"class":107},[93,2393,194],{"class":107},[93,2395,2396,2398,2400],{"class":95,"line":147},[93,2397,892],{"class":190},[93,2399,202],{"class":107},[93,2401,897],{"class":137},[93,2403,2404,2406,2408,2410,2412,2414,2416,2418,2420,2422,2424,2426,2428],{"class":95,"line":224},[93,2405,902],{"class":107},[93,2407,905],{"class":137},[93,2409,202],{"class":107},[93,2411,111],{"class":107},[93,2413,912],{"class":114},[93,2415,118],{"class":107},[93,2417,255],{"class":107},[93,2419,919],{"class":137},[93,2421,202],{"class":107},[93,2423,111],{"class":107},[93,2425,439],{"class":114},[93,2427,118],{"class":107},[93,2429,695],{"class":107},[93,2431,2432,2434,2436,2438,2440,2442,2444,2446,2448,2450,2452,2454,2456],{"class":95,"line":239},[93,2433,902],{"class":107},[93,2435,905],{"class":137},[93,2437,202],{"class":107},[93,2439,111],{"class":107},[93,2441,942],{"class":114},[93,2443,118],{"class":107},[93,2445,255],{"class":107},[93,2447,919],{"class":137},[93,2449,202],{"class":107},[93,2451,111],{"class":107},[93,2453,955],{"class":114},[93,2455,118],{"class":107},[93,2457,695],{"class":107},[93,2459,2460,2462],{"class":95,"line":245},[93,2461,964],{"class":137},[93,2463,643],{"class":107},[93,2465,2466],{"class":95,"line":275},[93,2467,971],{"class":107},[93,2469,2470,2472,2474],{"class":95,"line":315},[93,2471,976],{"class":190},[93,2473,202],{"class":107},[93,2475,194],{"class":107},[93,2477,2478,2480,2482,2484,2486,2488,2490,2492,2494,2496,2498,2500,2502],{"class":95,"line":349},[93,2479,813],{"class":190},[93,2481,202],{"class":107},[93,2483,989],{"class":107},[93,2485,733],{"class":186},[93,2487,994],{"class":103},[93,2489,36],{"class":107},[93,2491,999],{"class":133},[93,2493,138],{"class":137},[93,2495,118],{"class":107},[93,2497,1006],{"class":114},[93,2499,118],{"class":107},[93,2501,309],{"class":137},[93,2503,643],{"class":107},[93,2505,2506,2508,2510,2512,2514,2516,2518,2520,2522,2524,2526,2528,2530],{"class":95,"line":384},[93,2507,1017],{"class":190},[93,2509,202],{"class":107},[93,2511,989],{"class":107},[93,2513,733],{"class":186},[93,2515,994],{"class":103},[93,2517,36],{"class":107},[93,2519,999],{"class":133},[93,2521,138],{"class":137},[93,2523,118],{"class":107},[93,2525,1036],{"class":114},[93,2527,118],{"class":107},[93,2529,309],{"class":137},[93,2531,643],{"class":107},[93,2533,2534],{"class":95,"line":830},[93,2535,971],{"class":107},[93,2537,2538],{"class":95,"line":840},[93,2539,150],{"class":107},[13,2541,1053,2542,1057,2544,1061,2546,1064,2548,1067],{},[90,2543,1056],{},[90,2545,1060],{},[90,2547,1056],{},[90,2549,1060],{},[62,2551,1071],{"id":1070},[13,2553,1074],{},[83,2555,2556],{"className":85,"code":1077,"language":87,"meta":88,"style":88},[90,2557,2558,2566,2574,2614,2654,2694,2700,2724],{"__ignoreMap":88},[93,2559,2560,2562,2564],{"class":95,"line":96},[93,2561,1084],{"class":190},[93,2563,202],{"class":107},[93,2565,194],{"class":107},[93,2567,2568,2570,2572],{"class":95,"line":127},[93,2569,1093],{"class":190},[93,2571,202],{"class":107},[93,2573,897],{"class":137},[93,2575,2576,2578,2580,2582,2584,2586,2588,2590,2592,2594,2596,2598,2600,2602,2604,2606,2608,2610,2612],{"class":95,"line":147},[93,2577,1102],{"class":107},[93,2579,1105],{"class":137},[93,2581,202],{"class":107},[93,2583,111],{"class":107},[93,2585,1112],{"class":114},[93,2587,118],{"class":107},[93,2589,255],{"class":107},[93,2591,905],{"class":137},[93,2593,202],{"class":107},[93,2595,111],{"class":107},[93,2597,1125],{"class":114},[93,2599,118],{"class":107},[93,2601,255],{"class":107},[93,2603,1132],{"class":137},[93,2605,202],{"class":107},[93,2607,111],{"class":107},[93,2609,1139],{"class":114},[93,2611,118],{"class":107},[93,2613,695],{"class":107},[93,2615,2616,2618,2620,2622,2624,2626,2628,2630,2632,2634,2636,2638,2640,2642,2644,2646,2648,2650,2652],{"class":95,"line":224},[93,2617,1102],{"class":107},[93,2619,1105],{"class":137},[93,2621,202],{"class":107},[93,2623,111],{"class":107},[93,2625,1156],{"class":114},[93,2627,118],{"class":107},[93,2629,255],{"class":107},[93,2631,905],{"class":137},[93,2633,202],{"class":107},[93,2635,111],{"class":107},[93,2637,1169],{"class":114},[93,2639,118],{"class":107},[93,2641,255],{"class":107},[93,2643,1132],{"class":137},[93,2645,202],{"class":107},[93,2647,111],{"class":107},[93,2649,1156],{"class":114},[93,2651,118],{"class":107},[93,2653,695],{"class":107},[93,2655,2656,2658,2660,2662,2664,2666,2668,2670,2672,2674,2676,2678,2680,2682,2684,2686,2688,2690,2692],{"class":95,"line":239},[93,2657,1102],{"class":107},[93,2659,1105],{"class":137},[93,2661,202],{"class":107},[93,2663,111],{"class":107},[93,2665,1198],{"class":114},[93,2667,118],{"class":107},[93,2669,255],{"class":107},[93,2671,905],{"class":137},[93,2673,202],{"class":107},[93,2675,111],{"class":107},[93,2677,1211],{"class":114},[93,2679,118],{"class":107},[93,2681,255],{"class":107},[93,2683,1132],{"class":137},[93,2685,202],{"class":107},[93,2687,111],{"class":107},[93,2689,1224],{"class":114},[93,2691,118],{"class":107},[93,2693,695],{"class":107},[93,2695,2696,2698],{"class":95,"line":245},[93,2697,1233],{"class":137},[93,2699,643],{"class":107},[93,2701,2702,2704,2706,2708,2710,2712,2714,2716,2718,2720,2722],{"class":95,"line":275},[93,2703,1240],{"class":190},[93,2705,202],{"class":107},[93,2707,1245],{"class":214},[93,2709,733],{"class":186},[93,2711,994],{"class":103},[93,2713,36],{"class":107},[93,2715,1254],{"class":133},[93,2717,138],{"class":137},[93,2719,1259],{"class":103},[93,2721,309],{"class":137},[93,2723,643],{"class":107},[93,2725,2726],{"class":95,"line":315},[93,2727,150],{"class":107},[13,2729,1270,2730,1273,2732,1276,2734,1282,2737,1285],{},[90,2731,1139],{},[90,2733,1224],{},[17,2735,1281],{"href":1279,"rel":2736},[44],[90,2738,391],{},[57,2740,1289],{"id":1288},[62,2742,1293],{"id":1292},[13,2744,1296,2745,1302],{},[17,2746,1301],{"href":1299,"rel":2747},[44],[13,2749,1305],{},[83,2751,2752],{"className":85,"code":1308,"language":87,"meta":88,"style":88},[90,2753,2754,2764,2796,2828,2832,2836,2860,2878,2918,2922],{"__ignoreMap":88},[93,2755,2756,2758,2760,2762],{"class":95,"line":96},[93,2757,495],{"class":186},[93,2759,1317],{"class":103},[93,2761,501],{"class":107},[93,2763,897],{"class":103},[93,2765,2766,2768,2770,2772,2774,2776,2778,2780,2782,2784,2786,2788,2790,2792,2794],{"class":95,"line":127},[93,2767,1326],{"class":107},[93,2769,1329],{"class":137},[93,2771,202],{"class":107},[93,2773,111],{"class":107},[93,2775,1336],{"class":114},[93,2777,118],{"class":107},[93,2779,255],{"class":107},[93,2781,1343],{"class":137},[93,2783,202],{"class":107},[93,2785,453],{"class":103},[93,2787,118],{"class":107},[93,2789,1352],{"class":114},[93,2791,118],{"class":107},[93,2793,1357],{"class":103},[93,2795,1360],{"class":107},[93,2797,2798,2800,2802,2804,2806,2808,2810,2812,2814,2816,2818,2820,2822,2824,2826],{"class":95,"line":147},[93,2799,1326],{"class":107},[93,2801,1329],{"class":137},[93,2803,202],{"class":107},[93,2805,111],{"class":107},[93,2807,1373],{"class":114},[93,2809,118],{"class":107},[93,2811,255],{"class":107},[93,2813,1343],{"class":137},[93,2815,202],{"class":107},[93,2817,453],{"class":103},[93,2819,118],{"class":107},[93,2821,1388],{"class":114},[93,2823,118],{"class":107},[93,2825,1357],{"class":103},[93,2827,1360],{"class":107},[93,2829,2830],{"class":95,"line":224},[93,2831,1399],{"class":103},[93,2833,2834],{"class":95,"line":239},[93,2835,523],{"emptyLinePlaceholder":522},[93,2837,2838,2840,2842,2844,2846,2848,2850,2852,2854,2856,2858],{"class":95,"line":245},[93,2839,1408],{"class":99},[93,2841,736],{"class":103},[93,2843,495],{"class":186},[93,2845,284],{"class":107},[93,2847,1329],{"class":103},[93,2849,255],{"class":107},[93,2851,1421],{"class":103},[93,2853,1424],{"class":107},[93,2855,1427],{"class":107},[93,2857,1430],{"class":103},[93,2859,124],{"class":107},[93,2861,2862,2864,2866,2868,2870,2872,2874,2876],{"class":95,"line":275},[93,2863,1437],{"class":99},[93,2865,736],{"class":137},[93,2867,495],{"class":186},[93,2869,1444],{"class":103},[93,2871,1427],{"class":107},[93,2873,1449],{"class":103},[93,2875,121],{"class":137},[93,2877,124],{"class":107},[93,2879,2880,2882,2884,2886,2888,2890,2892,2894,2896,2898,2900,2902,2904,2906,2908,2910,2912,2914,2916],{"class":95,"line":315},[93,2881,1458],{"class":133},[93,2883,138],{"class":137},[93,2885,1463],{"class":133},[93,2887,138],{"class":137},[93,2889,484],{"class":103},[93,2891,255],{"class":107},[93,2893,1444],{"class":103},[93,2895,1474],{"class":137},[93,2897,36],{"class":107},[93,2899,1479],{"class":133},[93,2901,138],{"class":137},[93,2903,1484],{"class":103},[93,2905,36],{"class":107},[93,2907,1489],{"class":133},[93,2909,138],{"class":137},[93,2911,73],{"class":103},[93,2913,36],{"class":107},[93,2915,806],{"class":103},[93,2917,1500],{"class":137},[93,2919,2920],{"class":95,"line":349},[93,2921,242],{"class":107},[93,2923,2924],{"class":95,"line":384},[93,2925,150],{"class":107},[13,2927,1511],{},[62,2929,1515],{"id":1514},[13,2931,1518,2932,1524],{},[17,2933,1523],{"href":1521,"rel":2934},[44],[83,2936,2937],{"className":85,"code":1527,"language":87,"meta":88,"style":88},[90,2938,2939,2947,2957,2983,3031,3057],{"__ignoreMap":88},[93,2940,2941,2943,2945],{"class":95,"line":96},[93,2942,1534],{"class":133},[93,2944,138],{"class":103},[93,2946,124],{"class":107},[93,2948,2949,2951,2953,2955],{"class":95,"line":127},[93,2950,1543],{"class":137},[93,2952,202],{"class":107},[93,2954,994],{"class":103},[93,2956,643],{"class":107},[93,2958,2959,2961,2963,2965,2967,2969,2971,2973,2975,2977,2979,2981],{"class":95,"line":147},[93,2960,1554],{"class":133},[93,2962,202],{"class":107},[93,2964,1559],{"class":214},[93,2966,733],{"class":186},[93,2968,1564],{"class":133},[93,2970,138],{"class":103},[93,2972,118],{"class":107},[93,2974,1571],{"class":114},[93,2976,118],{"class":107},[93,2978,255],{"class":107},[93,2980,1578],{"class":103},[93,2982,643],{"class":107},[93,2984,2985,2987,2989,2991,2993,2995,2997,2999,3001,3003,3005,3007,3009,3011,3013,3015,3017,3019,3021,3023,3025,3027,3029],{"class":95,"line":224},[93,2986,1585],{"class":133},[93,2988,202],{"class":107},[93,2990,1559],{"class":214},[93,2992,733],{"class":186},[93,2994,1564],{"class":133},[93,2996,138],{"class":103},[93,2998,118],{"class":107},[93,3000,1600],{"class":114},[93,3002,118],{"class":107},[93,3004,255],{"class":107},[93,3006,1559],{"class":103},[93,3008,255],{"class":107},[93,3010,284],{"class":107},[93,3012,1613],{"class":137},[93,3014,202],{"class":107},[93,3016,453],{"class":103},[93,3018,118],{"class":107},[93,3020,458],{"class":114},[93,3022,118],{"class":107},[93,3024,1357],{"class":103},[93,3026,1424],{"class":107},[93,3028,309],{"class":103},[93,3030,643],{"class":107},[93,3032,3033,3035,3037,3039,3041,3043,3045,3047,3049,3051,3053,3055],{"class":95,"line":239},[93,3034,1636],{"class":133},[93,3036,202],{"class":107},[93,3038,1559],{"class":214},[93,3040,733],{"class":186},[93,3042,1645],{"class":133},[93,3044,1648],{"class":103},[93,3046,255],{"class":107},[93,3048,111],{"class":107},[93,3050,1655],{"class":114},[93,3052,118],{"class":107},[93,3054,309],{"class":103},[93,3056,643],{"class":107},[93,3058,3059,3061],{"class":95,"line":245},[93,3060,1424],{"class":107},[93,3062,144],{"class":103},[13,3064,1670],{},[13,3066,1673,3067,1679],{},[17,3068,1678],{"href":1676,"rel":3069},[44],[57,3071,1683],{"id":1682},[13,3073,1686],{},[1688,3075,3076,3078,3083,3085],{},[1691,3077,1693],{},[1691,3079,1696,3080,36],{},[17,3081,1701],{"href":1699,"rel":3082},[44],[1691,3084,1704],{},[1691,3086,1707],{},[1709,3088,1711],{},{"title":88,"searchDepth":127,"depth":127,"links":3090},[3091,3095,3099,3104,3108],{"id":59,"depth":127,"text":60,"children":3092},[3093,3094],{"id":64,"depth":147,"text":65},{"id":77,"depth":147,"text":78},{"id":156,"depth":127,"text":157,"children":3096},[3097,3098],{"id":160,"depth":147,"text":161},{"id":470,"depth":147,"text":471},{"id":600,"depth":127,"text":601,"children":3100},[3101,3102,3103],{"id":607,"depth":147,"text":608},{"id":857,"depth":147,"text":858},{"id":1070,"depth":147,"text":1071},{"id":1288,"depth":127,"text":1289,"children":3105},[3106,3107],{"id":1292,"depth":147,"text":1293},{"id":1514,"depth":147,"text":1515},{"id":1682,"depth":127,"text":1683},{},{"title":5,"description":1733},{"id":3112,"title":3113,"body":3114,"date":4004,"description":4005,"extension":1734,"meta":4006,"navigation":522,"path":4007,"seo":4008,"stem":4009,"tag":4010,"__hash__":4011},"updates_en\u002Fupdates\u002Frepos-without-ddev-config.md","Every Repo Boots, Even Without a DDEV Config",{"type":7,"value":3115,"toc":3983},[3116,3146,3150,3153,3232,3238,3242,3251,3369,3384,3387,3406,3410,3422,3436,3439,3443,3484,3488,3510,3514,3521,3524,3528,3539,3544,3548,3551,3721,3725,3742,3748,3752,3755,3759,3789,3812,3816,3833,3837,3848,3856,3863,3867,3907,3911,3943,3947,3953,3964,3967,3980],[13,3117,3118,3119,3122,3123,3128,3129,3134,3135,31,3140,3145],{},"Until now a repo needed a ",[90,3120,3121],{},".ddev\u002Fconfig.yaml",", or Knecht could not boot it. Since version 0.10 Knecht builds the environment from the files in the repo when there is no config. A dev server becomes the live preview, ",[17,3124,3127],{"href":3125,"rel":3126},"https:\u002F\u002Fvite.dev\u002Fguide\u002Ffeatures.html#hot-module-replacement",[44],"hot reload"," included. This post explains what Knecht detects, how the preview gets through, and what we learned about ",[17,3130,3133],{"href":3131,"rel":3132},"https:\u002F\u002Fddev.com\u002F",[44],"DDEV",", ",[17,3136,3139],{"href":3137,"rel":3138},"https:\u002F\u002Fdocs.docker.com\u002Fcompose\u002F",[44],"Docker Compose",[17,3141,3144],{"href":3142,"rel":3143},"https:\u002F\u002Fnuxt.com\u002F",[44],"Nuxt"," along the way.",[57,3147,3149],{"id":3148},"what-knecht-detects","What Knecht Detects",[13,3151,3152],{},"At the start of every run Knecht reads the files from the checkout that define the versions anyway:",[3154,3155,3156,3171],"table",{},[3157,3158,3159],"thead",{},[3160,3161,3162,3165,3168],"tr",{},[3163,3164],"th",{},[3163,3166,3167],{},"Source",[3163,3169,3170],{},"Rule",[3172,3173,3174,3192,3217],"tbody",{},[3160,3175,3176,3180,3189],{},[3177,3178,3179],"td",{},"PHP",[3177,3181,3182,3185,3186],{},[90,3183,3184],{},"require.php"," in ",[90,3187,3188],{},"composer.json",[3177,3190,3191],{},"the highest version of the DDEV image that satisfies the constraint",[3160,3193,3194,3197,3214],{},[3177,3195,3196],{},"Node",[3177,3198,3199,3134,3202,3134,3205,3134,3208,3134,3211],{},[90,3200,3201],{},"mise.toml",[90,3203,3204],{},".mise.toml",[90,3206,3207],{},".tool-versions",[90,3209,3210],{},".nvmrc",[90,3212,3213],{},"engines.node",[3177,3215,3216],{},"the first file with a value wins",[3160,3218,3219,3222,3229],{},[3177,3220,3221],{},"Package manager",[3177,3223,3224,3225,3228],{},"the ",[90,3226,3227],{},"packageManager"," field, else the lockfile",[3177,3230,3231],{},"npm, pnpm, yarn or bun",[13,3233,3234,3235,36],{},"Where nothing is set, the DDEV default applies. The run log names every value with its source, for example ",[90,3236,3237],{},"PHP 8.2 from composer.json, Node 22 from .nvmrc",[62,3239,3241],{"id":3240},"the-generated-config","The Generated Config",[13,3243,3244,3245,3247,3248,3250],{},"From those values Knecht writes a ",[90,3246,3121],{}," into the checkout. For a Nuxt repo that pins Node 22 and has no ",[90,3249,3188],{},", it looks like this:",[83,3252,3256],{"className":3253,"code":3254,"language":3255,"meta":88,"style":88},"language-yaml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","#knecht-generated\nname: knecht-run-42\ntype: php\ndocroot: \"\"\nwebserver_type: generic\nphp_version: \"8.4\"\nnodejs_version: \"22\"\nomit_containers:\n  - db\ncorepack_enable: true\ndisable_settings_management: true\n","yaml",[90,3257,3258,3264,3273,3283,3293,3303,3319,3333,3341,3349,3360],{"__ignoreMap":88},[93,3259,3260],{"class":95,"line":96},[93,3261,3263],{"class":3262},"sHwdD","#knecht-generated\n",[93,3265,3266,3268,3270],{"class":95,"line":127},[93,3267,806],{"class":137},[93,3269,202],{"class":107},[93,3271,3272],{"class":114}," knecht-run-42\n",[93,3274,3275,3278,3280],{"class":95,"line":147},[93,3276,3277],{"class":137},"type",[93,3279,202],{"class":107},[93,3281,3282],{"class":114}," php\n",[93,3284,3285,3288,3290],{"class":95,"line":224},[93,3286,3287],{"class":137},"docroot",[93,3289,202],{"class":107},[93,3291,3292],{"class":107}," \"\"\n",[93,3294,3295,3298,3300],{"class":95,"line":239},[93,3296,3297],{"class":137},"webserver_type",[93,3299,202],{"class":107},[93,3301,3302],{"class":114}," generic\n",[93,3304,3305,3308,3310,3313,3316],{"class":95,"line":245},[93,3306,3307],{"class":137},"php_version",[93,3309,202],{"class":107},[93,3311,3312],{"class":107}," \"",[93,3314,3315],{"class":114},"8.4",[93,3317,3318],{"class":107},"\"\n",[93,3320,3321,3324,3326,3328,3331],{"class":95,"line":275},[93,3322,3323],{"class":137},"nodejs_version",[93,3325,202],{"class":107},[93,3327,3312],{"class":107},[93,3329,3330],{"class":114},"22",[93,3332,3318],{"class":107},[93,3334,3335,3338],{"class":95,"line":315},[93,3336,3337],{"class":137},"omit_containers",[93,3339,3340],{"class":107},":\n",[93,3342,3343,3346],{"class":95,"line":349},[93,3344,3345],{"class":107},"  -",[93,3347,3348],{"class":114}," db\n",[93,3350,3351,3354,3356],{"class":95,"line":384},[93,3352,3353],{"class":137},"corepack_enable",[93,3355,202],{"class":107},[93,3357,3359],{"class":3358},"sfNiH"," true\n",[93,3361,3362,3365,3367],{"class":95,"line":830},[93,3363,3364],{"class":137},"disable_settings_management",[93,3366,202],{"class":107},[93,3368,3359],{"class":3358},[13,3370,3371,3372,3375,3376,3383],{},"The project type is ",[90,3373,3374],{},"php",", because DDEV makes no CMS assumptions for it. The web server is ",[17,3377,3380],{"href":3378,"rel":3379},"https:\u002F\u002Fdocs.ddev.com\u002Fen\u002Fstable\u002Fusers\u002Fconfiguration\u002Fconfig\u002F#webserver_type",[44],[90,3381,3382],{},"generic",", so neither nginx nor php-fpm runs, since without a website there is nothing to serve. The database container is left out, Corepack is enabled, and DDEV writes no settings files into the repo. Ports, environment variables and daemons go into Knecht's own override files next to it, as with every project. More on those below.",[13,3385,3386],{},"Knecht needs the comment on the first line so that on every further run it can tell for sure that this is a project without its own DDEV config, even though there is one in the checkout now.",[13,3388,3389,3390,3393,3394,3401,3402,3405],{},"The marker also makes sure none of this ends up in a commit. The Git actions in a run commit everything that is in the checkout. So before the first run Knecht writes the ",[90,3391,3392],{},".ddev\u002F"," directory into the clone's ",[17,3395,3398],{"href":3396,"rel":3397},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgitignore",[44],[90,3399,3400],{},".git\u002Finfo\u002Fexclude"," file. That is an ignore list that applies only to this clone and is not part of the repo itself, Git treats its entries like a ",[90,3403,3404],{},".gitignore",". For a repo with its own config only Knecht's own files go in there, for a generated one the whole directory, because DDEV also drops files into it on start. Which case applies, Knecht reads off the marker.",[62,3407,3409],{"id":3408},"package-manager","Package Manager",[13,3411,3412,3413,3418,3419,3421],{},"npm is part of the DDEV image and the default when the repo sets nothing else. ",[17,3414,3417],{"href":3415,"rel":3416},"https:\u002F\u002Fnodejs.org\u002Fapi\u002Fcorepack.html",[44],"Corepack"," is enabled in every generated environment, so pnpm and yarn run without an install. Corepack downloads the version from the ",[90,3420,3227],{}," field, nothing has to be installed in the repo.",[13,3423,3424,3429,3430,3435],{},[17,3425,3428],{"href":3426,"rel":3427},"https:\u002F\u002Fbun.sh\u002F",[44],"bun"," is missing from the image. Knecht adds one line to the image build that installs bun through npm. The layer stays in the ",[17,3431,3434],{"href":3432,"rel":3433},"https:\u002F\u002Fdocs.docker.com\u002Fbuild\u002Fcache\u002F",[44],"Docker cache",", only the first bun session on a server builds it.",[13,3437,3438],{},"The downloads of all four go into DDEV's server-wide cache. DDEV points Composer, npm and Corepack there itself, Knecht adds the paths for the pnpm store, yarn and bun, for every project, including one with its own DDEV config. A server downloads each package once, later sessions install from the cache.",[62,3440,3442],{"id":3441},"node-version","Node Version",[13,3444,3445,3446,3448,3449,3452,3453,3458,3459,3462,3463,3448,3466,3469,3470,3185,3476,3479,3480,3483],{},"Concrete versions like ",[90,3447,3330],{}," or ",[90,3450,3451],{},"v20.11"," are taken up to the minor version, ",[17,3454,3457],{"href":3455,"rel":3456},"https:\u002F\u002Fgithub.com\u002Fnvm-sh\u002Fnvm#nvmrc",[44],"nvm"," code names like ",[90,3460,3461],{},"lts\u002Firon"," as their major version. Moving targets like ",[90,3464,3465],{},"lts\u002F*",[90,3467,3468],{},"latest"," are not pinned. The default applies then, with a warning in the run log. ",[17,3471,3474],{"href":3472,"rel":3473},"https:\u002F\u002Fdocs.npmjs.com\u002Fcli\u002Fv10\u002Fconfiguring-npm\u002Fpackage-json#engines",[44],[90,3475,3213],{},[90,3477,3478],{},"package.json"," is usually a range like ",[90,3481,3482],{},">=20",". If the DDEV default satisfies the range, it stays, otherwise Knecht takes the highest LTS version that does.",[62,3485,3487],{"id":3486},"php-version","PHP Version",[13,3489,3490,3491,3496,3497,3448,3500,3503,3504,3509],{},"The choice is the versions of the DDEV image, currently 5.6 to 8.4. Composer ",[17,3492,3495],{"href":3493,"rel":3494},"https:\u002F\u002Fgetcomposer.org\u002Fdoc\u002Farticles\u002Fversions.md",[44],"constraints"," like ",[90,3498,3499],{},"^8.1",[90,3501,3502],{},">=8.1,\u003C8.3"," are translated into a ",[17,3505,3508],{"href":3506,"rel":3507},"https:\u002F\u002Fgithub.com\u002Fnpm\u002Fnode-semver#ranges",[44],"semver range",", and Knecht takes the highest version that satisfies it. If none does, the default applies, with a warning in the run log.",[62,3511,3513],{"id":3512},"the-result-in-the-settings","The Result in the Settings",[13,3515,3516],{},[3517,3518],"img",{"alt":3519,"src":3520},"Project settings of a Nuxt repo without a DDEV config: the \"Environment\" card with PHP 8.4, Node 24 and pnpm, below it the boot commands and the \"Dev Server\" card with command and port","\u002Fassets\u002Fknecht-project-settings-noddev.png",[13,3522,3523],{},"The \"Environment\" card in the project settings shows the detected values with their source. If you want, you pick a different PHP version, a different Node version or a different package manager there. Detection already runs when you connect the repo, through the GitHub API and with the same logic as the boot. Warnings, for example about a constraint no version satisfies, show up in the card as well.",[57,3525,3527],{"id":3526},"preview","Preview",[13,3529,3530,3531,3534,3535,3538],{},"A generated environment has no website. That is why there is a \"Dev Server\" card with the command that starts the dev server, for example ",[90,3532,3533],{},"npm run dev",", and its port. Knecht starts it after the boot commands and serves it as the session's preview. The command receives the preview URL as ",[90,3536,3537],{},"KNECHT_PREVIEW_URL",". Without a dev server the environment boots without a preview. Changes to the \"Environment\" and \"Dev Server\" cards take effect with the session's next run.",[3540,3541],"update-video",{"caption":3542,"src":3543},"A Nuxt dev server as the preview. The code change shows up without a reload.","\u002Fassets\u002Fhmr-nuxt-knecht.mp4",[62,3545,3547],{"id":3546},"hot-reload","Hot Reload",[13,3549,3550],{},"Hot reload works in the preview like it does locally, without changes in the repo. Getting a Vite dev server through took some work, though.",[3552,3553,3555,3560,3575,3646,3657,3661,3671,3675,3696,3700],"steps",{"level":3554},"4",[3556,3557,3559],"h4",{"id":3558},"the-daemon-in-the-container","The Daemon in the Container",[13,3561,3562,3563,3568,3569,3574],{},"The dev server runs as a daemon under ",[17,3564,3567],{"href":3565,"rel":3566},"https:\u002F\u002Fsupervisord.org\u002F",[44],"supervisord"," in the web container, the way DDEV intends for ",[17,3570,3573],{"href":3571,"rel":3572},"https:\u002F\u002Fdocs.ddev.com\u002Fen\u002Fstable\u002Fusers\u002Fconfiguration\u002Fconfig\u002F#web_extra_daemons",[44],"web_extra_daemons",". Knecht adds it to its override file, together with the forwarder from the next step:",[83,3576,3578],{"className":3253,"code":3577,"language":3255,"meta":88,"style":88},"web_extra_daemons:\n  - name: knecht-dev\n    command: bash -lc 'npm run dev'\n    directory: \u002Fvar\u002Fwww\u002Fhtml\n  - name: knecht-forward\n    command: knecht-forward 41000 3000\n    directory: \u002Fvar\u002Fwww\u002Fhtml\n",[90,3579,3580,3586,3598,3608,3618,3629,3638],{"__ignoreMap":88},[93,3581,3582,3584],{"class":95,"line":96},[93,3583,3573],{"class":137},[93,3585,3340],{"class":107},[93,3587,3588,3590,3593,3595],{"class":95,"line":127},[93,3589,3345],{"class":107},[93,3591,3592],{"class":137}," name",[93,3594,202],{"class":107},[93,3596,3597],{"class":114}," knecht-dev\n",[93,3599,3600,3603,3605],{"class":95,"line":147},[93,3601,3602],{"class":137},"    command",[93,3604,202],{"class":107},[93,3606,3607],{"class":114}," bash -lc 'npm run dev'\n",[93,3609,3610,3613,3615],{"class":95,"line":224},[93,3611,3612],{"class":137},"    directory",[93,3614,202],{"class":107},[93,3616,3617],{"class":114}," \u002Fvar\u002Fwww\u002Fhtml\n",[93,3619,3620,3622,3624,3626],{"class":95,"line":239},[93,3621,3345],{"class":107},[93,3623,3592],{"class":137},[93,3625,202],{"class":107},[93,3627,3628],{"class":114}," knecht-forward\n",[93,3630,3631,3633,3635],{"class":95,"line":245},[93,3632,3602],{"class":137},[93,3634,202],{"class":107},[93,3636,3637],{"class":114}," knecht-forward 41000 3000\n",[93,3639,3640,3642,3644],{"class":95,"line":275},[93,3641,3612],{"class":137},[93,3643,202],{"class":107},[93,3645,3617],{"class":114},[13,3647,3648,3649,3652,3653,3656],{},"The command runs in a login shell so the image's profile applies. DDEV starts the daemon right after ",[90,3650,3651],{},"ddev start",", before the boot commands have run ",[90,3654,3655],{},"npm ci",". So the first start dies, and supervisord gives up after a few attempts. Knecht restarts the daemon group after the boot commands and then polls until something answers on the port. Only then does the preview count as ready.",[3556,3658,3660],{"id":3659},"the-forwarder","The Forwarder",[13,3662,3663,3664,3667,3668,3670],{},"Vite, Nuxt and Next bind to ",[90,3665,3666],{},"127.0.0.1"," only by default. The preview proxy reaches the container from outside though, through its IP on the Docker network. So a small forwarder runs next to it as a second daemon, a Node script of a few lines. It listens on all interfaces of the container on a fixed port and passes every TCP connection on to ",[90,3669,3666],{}," and the dev server's port. Everything from the host targets the forwarder, the boot's polling included, so the same path is checked that the browser takes later.",[3556,3672,3674],{"id":3673},"the-proxy","The Proxy",[13,3676,3677,3678,3681,3682,3685,3686,3691,3692,3695],{},"A request to ",[90,3679,3680],{},"\u003Csession>.preview.\u003Chost>"," lands at Knecht's preview proxy. It checks the login cookie, resolves the IP of the web container and passes the request on to the forwarder over HTTP. The ",[90,3683,3684],{},"Host"," header stays the preview host, because the dev server has no hostname of its own. Hot reload runs over a ",[17,3687,3690],{"href":3688,"rel":3689},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWebSockets_API",[44],"WebSocket"," connection. The proxy forwards the upgrade to the same port and passes along the subprotocol the browser asked for, because Vite only completes the HMR upgrade with ",[90,3693,3694],{},"vite-hmr",". Since the forwarder passes raw TCP, the WebSocket gets through without anything else.",[3556,3697,3699],{"id":3698},"the-host-check","The Host Check",[13,3701,3702,3703,3708,3709,3712,3713,3720],{},"Vite rejects requests for unknown hostnames, see ",[17,3704,3707],{"href":3705,"rel":3706},"https:\u002F\u002Fvite.dev\u002Fconfig\u002Fserver-options.html#server-allowedhosts",[44],"server.allowedHosts",". Since Vite 5.4.12 and 6.0.9 the dev server reads one extra allowed host from the environment variable ",[90,3710,3711],{},"__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS",", and Knecht sets it to the preview host. Nuxt uses Vite and is covered by that. Next.js keeps its own list in ",[17,3714,3717],{"href":3715,"rel":3716},"https:\u002F\u002Fnextjs.org\u002Fdocs\u002Fapp\u002Fapi-reference\u002Fconfig\u002Fnext-config-js\u002FallowedDevOrigins",[44],[90,3718,3719],{},"allowedDevOrigins",", which still comes from the repo.",[62,3722,3724],{"id":3723},"a-dev-server-next-to-your-own-config","A Dev Server Next to Your Own Config",[13,3726,3727,3728,3733,3734,3737,3738,3741],{},"Since the same release a repo with its own DDEV config can name a dev server too, for example Vite for HMR next to a ",[17,3729,3732],{"href":3730,"rel":3731},"https:\u002F\u002Fcraftcms.com\u002F",[44],"Craft"," site. The dev server does not replace the site then. The repo's hostnames still reach the web server, and the dev server gets its own preview origin, which the container sees as ",[90,3735,3736],{},"KNECHT_DEV_SERVER_URL",". That is where the repo points its asset URL, for example with an env value like ",[90,3739,3740],{},"VITE_DEV_SERVER_PUBLIC=$KNECHT_DEV_SERVER_URL"," in the project settings.",[13,3743,3744,3745,3747],{},"The page loads the dev server as a module script, and browsers fetch module scripts across origins without cookies. So the preview's login cookie never arrives there. That is why the dev server's origin carries a per-session token in its hostname instead of relying on the cookie. None of this is visible, ",[90,3746,3736],{}," holds the finished URL.",[57,3749,3751],{"id":3750},"learned-along-the-way","Learned Along the Way",[13,3753,3754],{},"Part of the work on this release was less about detection and more about how things fit together with DDEV, Docker Compose and Nuxt. Five things from that are useful outside Knecht too.",[62,3756,3758],{"id":3757},"ddev-merges-by-file-name","DDEV Merges by File Name",[13,3760,3761,3762,3185,3765,3767,3768,3773,3774,3777,3778,3781,3782,31,3785,3788],{},"DDEV reads every ",[90,3763,3764],{},"config.*.yaml",[90,3766,3392],{}," and merges them in alphabetical order. For scalars the last file wins, lists are joined (",[17,3769,3772],{"href":3770,"rel":3771},"https:\u002F\u002Fdocs.ddev.com\u002Fen\u002Fstable\u002Fusers\u002Fextend\u002Fcustomization-extendibility\u002F",[44],"docs","). Knecht's override was called ",[90,3775,3776],{},"config.knecht.yaml",". A repo with a ",[90,3779,3780],{},"config.vite.yaml"," sorted after it and could override the run name or the environment. Knecht's files are now called ",[90,3783,3784],{},"config.zzz-knecht.yaml",[90,3786,3787],{},"docker-compose.zzz-knecht.yaml",", so they are read last.",[13,3790,3791,3792,3795,3796,3799,3800,3807,3808,3811],{},"The merge also ignores zero values. An ",[90,3793,3794],{},"xdebug_enabled: false"," in an override file does not cancel a ",[90,3797,3798],{},"true"," from the main config. No debugger runs on a Knecht server, and every request would have waited for one. So Knecht turns Xdebug off through ",[17,3801,3804],{"href":3802,"rel":3803},"https:\u002F\u002Fxdebug.org\u002Fdocs\u002Fall_settings",[44],[90,3805,3806],{},"XDEBUG_MODE=off"," in the environment. The variable beats the ini, for php-fpm and the CLI. Your own ",[90,3809,3810],{},"XDEBUG_MODE"," line in the project settings still wins.",[62,3813,3815],{"id":3814},"variables-without-a-router","Variables Without a Router",[13,3817,3818,3819,3134,3826,31,3829,3832],{},"Without a router DDEV leaves ",[17,3820,3823],{"href":3821,"rel":3822},"https:\u002F\u002Fdocs.ddev.com\u002Fen\u002Fstable\u002Fusers\u002Fextend\u002Fcustom-commands\u002F#environment-variables-provided",[44],[90,3824,3825],{},"DDEV_PRIMARY_URL",[90,3827,3828],{},"DDEV_HOSTNAME",[90,3830,3831],{},"DDEV_SCHEME"," empty. There is no router on Knecht servers, the preview proxy talks to the web containers directly. But the settings files DDEV itself writes for Drupal, TYPO3 and WordPress build their URLs from exactly these variables, so they pointed nowhere. Knecht now writes them itself, the way DDEV does locally, with the session's preview URL as the value.",[62,3834,3836],{"id":3835},"ports-from-the-project-list","Ports From the Project List",[13,3838,3839,3840,3847],{},"Two parallel runs of the same project need different host ports, even when the repo pins a ",[17,3841,3844],{"href":3842,"rel":3843},"https:\u002F\u002Fdocs.ddev.com\u002Fen\u002Fstable\u002Fusers\u002Fconfiguration\u002Fconfig\u002F#host_db_port",[44],[90,3845,3846],{},"host_db_port"," for a developer's GUI client. Knecht asked the operating system for free ports, and DDEV refused the start anyway:",[83,3849,3854],{"className":3850,"code":3852,"language":3853},[3851],"language-text","host port 36865 has already been allocated to project knecht-run-6\n","text",[90,3855,3852],{"__ignoreMap":88},[13,3857,3858,3859,3862],{},"DDEV remembers the ports of every project in ",[90,3860,3861],{},"~\u002F.ddev\u002Fproject_list.yaml",", stopped ones included, and checks against that list. Knecht now reads the list too and keeps going until it has ports DDEV does not know.",[62,3864,3866],{"id":3865},"docker-compose-expands-against-the-host","Docker Compose Expands Against the Host",[13,3868,3869,3870,3875,3876,3879,3880,3883,3884,3886,3887,3889,3890,3893,3894,3897,3898,3901,3902,3904,3905,36],{},"The env values from the project settings reach the container through a Compose file. Docker Compose ",[17,3871,3874],{"href":3872,"rel":3873},"https:\u002F\u002Fdocs.docker.com\u002Fcompose\u002Fhow-tos\u002Fenvironment-variables\u002Fvariable-interpolation\u002F",[44],"interpolates"," a ",[90,3877,3878],{},"$NAME"," in that file against the host's environment, not the container's. An unset name became an empty string, a password with a ",[90,3881,3882],{},"$"," arrived mangled. Knecht now expands references itself, against ",[90,3885,3537],{}," and the earlier lines, and escapes every remaining ",[90,3888,3882],{}," as ",[90,3891,3892],{},"$$",". So ",[90,3895,3896],{},"APP_URL=$KNECHT_PREVIEW_URL"," becomes the session's preview URL, ",[90,3899,3900],{},"SITE_URL=${APP_URL}\u002Fen"," builds on it, and a ",[90,3903,3882],{}," in a password stays a ",[90,3906,3882],{},[62,3908,3910],{"id":3909},"nuxt-behind-nuxt","Nuxt Behind Nuxt",[13,3912,3913,3914,3917,3918,3923,3924,3929,3930,3134,3933,3938,3939,3942],{},"The dashboard is a Nuxt app, and the preview of a Nuxt project runs through a proxy in the same app. Both serve their assets under ",[90,3915,3916],{},"\u002F_nuxt\u002F",". ",[17,3919,3922],{"href":3920,"rel":3921},"https:\u002F\u002Fvite.dev\u002F",[44],"Vite","'s dev middleware and ",[17,3925,3928],{"href":3926,"rel":3927},"https:\u002F\u002Fnitro.build\u002F",[44],"Nitro","'s static handler answer requests on that path before any server middleware gets a turn. So the preview's assets arrived at the dashboard and went nowhere, a 404 in production and ENOENT in dev mode. The fix is one line in ",[90,3931,3932],{},"nuxt.config.ts",[17,3934,3937],{"href":3935,"rel":3936},"https:\u002F\u002Fnuxt.com\u002Fdocs\u002Fapi\u002Fnuxt-config#buildassetsdir",[44],"buildAssetsDir"," set to ",[90,3940,3941],{},"\u002F_knecht\u002F",". If you proxy a Nuxt app through a Nuxt app, you need it too.",[57,3944,3946],{"id":3945},"limitations","Limitations",[13,3948,3949,3950,3952],{},"The generated environment is a DDEV project with PHP and Node, nothing more. That runs everything that gets by with those two, so libraries, plugins, frontends and Node tools. Anything beyond that still needs its own ",[90,3951,3121],{}," in the repo:",[1688,3954,3955,3958,3961],{},[1691,3956,3957],{},"a database, because the generated environment has no database container",[1691,3959,3960],{},"additional services like Redis or Elasticsearch",[1691,3962,3963],{},"other runtimes like Python, Ruby or Go, which the DDEV image does not ship",[13,3965,3966],{},"Repos that bring their own docker-compose file instead of DDEV still cannot be booted by Knecht.",[3968,3969,3972],"callout",{"color":3970,"icon":3971},"neutral","i-lucide-arrow-right",[13,3973,3974,3975,3979],{},"Cases where a project runs differently on Knecht than locally are collected in the docs under ",[17,3976,3978],{"href":3977},"\u002Fdocs\u002Fresources\u002Ftroubleshooting","Troubleshooting",", with the fix per framework.",[1709,3981,3982],{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":88,"searchDepth":127,"depth":127,"links":3984},[3985,3992,3996,4003],{"id":3148,"depth":127,"text":3149,"children":3986},[3987,3988,3989,3990,3991],{"id":3240,"depth":147,"text":3241},{"id":3408,"depth":147,"text":3409},{"id":3441,"depth":147,"text":3442},{"id":3486,"depth":147,"text":3487},{"id":3512,"depth":147,"text":3513},{"id":3526,"depth":127,"text":3527,"children":3993},[3994,3995],{"id":3546,"depth":147,"text":3547},{"id":3723,"depth":147,"text":3724},{"id":3750,"depth":127,"text":3751,"children":3997},[3998,3999,4000,4001,4002],{"id":3757,"depth":147,"text":3758},{"id":3814,"depth":147,"text":3815},{"id":3835,"depth":147,"text":3836},{"id":3865,"depth":147,"text":3866},{"id":3909,"depth":147,"text":3910},{"id":3945,"depth":127,"text":3946},"2026-09-11","Knecht now builds the environment from the repo files when there is no DDEV config. A dev server becomes the live preview, with hot reload.",{},"\u002Fupdates\u002Frepos-without-ddev-config",{"title":3113,"description":4005},"updates\u002Frepos-without-ddev-config","Engine","UxENQcx8_4cTqWqktujPo1cDfY2mKPf7usDjZnDgTZ4",{"id":4013,"title":4014,"body":4015,"date":4255,"description":4256,"extension":1734,"meta":4257,"navigation":522,"path":4258,"seo":4259,"stem":4260,"tag":4261,"__hash__":4262},"updates_en\u002Fupdates\u002Fdocs-online.md","The Docs Are Online",{"type":7,"value":4016,"toc":4243},[4017,4025,4029,4032,4060,4063,4067,4071,4083,4089,4093,4102,4108,4111,4115,4118,4134,4138,4141,4155,4159,4162,4220,4224,4227,4231,4234],[13,4018,4019,4020,4024],{},"Until now these updates were all there was about Knecht. They tell why something is built the way it is, but not how to set it up. Anyone who wanted to install an instance or build a workflow had to piece the steps together from several posts. Since this week there is documentation at ",[17,4021,4023],{"href":4022},"\u002Fdocs","knecht.works\u002Fdocs",". This post shows what is in it and what the pages can do beyond text.",[57,4026,4028],{"id":4027},"what-is-in-it","What Is in It",[13,4030,4031],{},"The docs are in English, like the dashboard, so the terms are the same on both sides. They consist of three parts.",[4033,4034,4035,4044,4052],"card-group",{},[4036,4037,4041],"card",{"icon":4038,"title":4039,"to":4040},"i-lucide-rocket","Get Started","\u002Fdocs\u002Fget-started\u002Fintroduction",[13,4042,4043],{},"What Knecht is, the terms from the dashboard, installation, setup, and day-to-day operation.",[4036,4045,4049],{"icon":4046,"title":4047,"to":4048},"i-lucide-workflow","Usage","\u002Fdocs\u002Fusage\u002Fprojects",[13,4050,4051],{},"Projects, workflows, triggers, and the AI agent, the things you work with every day.",[4036,4053,4057],{"icon":4054,"title":4055,"to":4056},"i-lucide-life-buoy","Resources","\u002Fdocs\u002Fresources\u002Fbeta-testers",[13,4058,4059],{},"The page for beta testers and a troubleshooting section per framework.",[13,4061,4062],{},"Much of it is not very detailed yet. The docs grow step by step with the product. Knecht itself still has some rough edges, and we polish those first before we document them in detail. Otherwise we would have to rewrite the pages shortly after.",[57,4064,4066],{"id":4065},"features","Features",[62,4068,4070],{"id":4069},"search","Search",[13,4072,4073,4074,4078,4079,4082],{},"At the top right there is a search, which also opens with ",[4075,4076],"kbd",{"value":4077},"meta"," ",[4075,4080],{"value":4081},"K",". It searches all docs pages down to the section level, so a hit jumps straight to the matching heading.",[13,4084,4085],{},[3517,4086],{"alt":4087,"src":4088},"The docs search above the \"Introduction\" page, with the results grouped by Get Started, Usage, and Resources","\u002Fassets\u002Fdocs-search.png",[62,4090,4092],{"id":4091},"ask-knecht","Ask Knecht",[13,4094,4095,4096,4078,4098,4101],{},"Next to the search there is the button \"Ask Knecht\", which also answers to ",[4075,4097],{"value":4077},[4075,4099],{"value":4100},"I",". It opens a chat window where you ask questions in full sentences, in German or English. The assistant answers from the content of the website, that is the docs and these updates, and links the page where the answer is. What is not on the website, it does not know, and it says so.",[13,4103,4104],{},[3517,4105],{"alt":4106,"src":4107},"The \"Ask Knecht\" panel next to the docs, with a question about the installation and an answer listing the requirements and the install command","\u002Fassets\u002Fdocs-ai.png",[13,4109,4110],{},"If you open the window on a docs page, the assistant gets that page as context and prefers it when answering. This can be switched off in the window. Behind it runs a Claude model that receives the complete content of the website with every question. Knecht does not store the chats.",[62,4112,4114],{"id":4113},"docs-as-markdown","Docs as Markdown",[13,4116,4117],{},"Every docs page is also available as plain Markdown. The button \"Copy page\" at the top of the page copies the text to the clipboard. The menu next to it offers the Markdown link, the raw view, and the option to open the page directly in ChatGPT or Claude. If you set up Knecht with a coding agent, this is how you hand it the right page, without HTML around it.",[4119,4120,4121],"tip",{},[13,4122,4123,4124,4128,4129,4133],{},"For everything at once there is ",[17,4125,4127],{"href":4126},"\u002Fllms.txt","llms.txt"," with the overview and ",[17,4130,4132],{"href":4131},"\u002Fllms-full.txt","llms-full.txt"," with the complete content of the website in one file.",[62,4135,4137],{"id":4136},"feedback-per-page","Feedback per Page",[13,4139,4140],{},"On every docs page you can tell us directly whether it helped and what is missing.",[1688,4142,4143,4146],{},[1691,4144,4145],{},"At the end of every page there is the question \"Was this page helpful?\" with thumbs up and thumbs down. One click is enough, we see per page how often each answer came in.",[1691,4147,4148,4149,4154],{},"\"Provide Feedback\" opens a GitHub issue in the ",[17,4150,4153],{"href":4151,"rel":4152},"https:\u002F\u002Fgithub.com\u002Fknecht-works\u002Fknecht-www",[44],"website repo",", with the page title already in the subject. The repo is public, so a typo can also be fixed directly as a pull request.",[62,4156,4158],{"id":4157},"shortcuts","Shortcuts",[13,4160,4161],{},"Search and \"Ask Knecht\" can be used entirely from the keyboard.",[3154,4163,4164,4174],{},[3157,4165,4166],{},[3160,4167,4168,4171],{},[3163,4169,4170],{},"Shortcut",[3163,4172,4173],{},"Action",[3172,4175,4176,4187,4198,4210],{},[3160,4177,4178,4184],{},[3177,4179,4180,4078,4182],{},[4075,4181],{"value":4077},[4075,4183],{"value":4081},[3177,4185,4186],{},"Open the search",[3160,4188,4189,4195],{},[3177,4190,4191,4078,4193],{},[4075,4192],{"value":4077},[4075,4194],{"value":4100},[3177,4196,4197],{},"Open and close \"Ask Knecht\"",[3160,4199,4200,4207],{},[3177,4201,4202,4078,4204],{},[4075,4203],{"value":4077},[4075,4205],{"value":4206},"O",[3177,4208,4209],{},"Start a new chat",[3160,4211,4212,4217],{},[3177,4213,4214],{},[4075,4215],{"value":4216},"tab",[3177,4218,4219],{},"Switch the current page as context on or off",[57,4221,4223],{"id":4222},"rough-edges","Rough Edges",[13,4225,4226],{},"The docs are new, and we wrote them as the people who know Knecht best. What is obvious to us may be missing for exactly those who set it up for the first time. That is why we are most interested in where you got stuck. Which page left a question open, which term is unexplained, which setup did not work as described. Small hints are often the most useful ones.",[57,4228,4230],{"id":4229},"discord","Discord",[13,4232,4233],{},"For everything that is not an issue, there is now a Discord server. There we answer questions about the setup, discuss workflow ideas, and show what we are building right now. If you try Knecht or plan to, you are welcome there, even without a specific question.",[3968,4235,4240],{"color":4236,"icon":4237,"target":4238,"to":4239},"primary","i-simple-icons-discord","_blank","https:\u002F\u002Fdiscord.gg\u002FWuxjmtgUyX",[13,4241,4242],{},"Join the Knecht Discord",{"title":88,"searchDepth":127,"depth":127,"links":4244},[4245,4246,4253,4254],{"id":4027,"depth":127,"text":4028},{"id":4065,"depth":127,"text":4066,"children":4247},[4248,4249,4250,4251,4252],{"id":4069,"depth":147,"text":4070},{"id":4091,"depth":147,"text":4092},{"id":4113,"depth":147,"text":4114},{"id":4136,"depth":147,"text":4137},{"id":4157,"depth":147,"text":4158},{"id":4222,"depth":127,"text":4223},{"id":4229,"depth":127,"text":4230},"2026-09-10","Knecht now has documentation with search, a chat assistant, and feedback on every page. And a Discord server where we collect your reports.",{},"\u002Fupdates\u002Fdocs-online",{"title":4014,"description":4256},"updates\u002Fdocs-online","Project","0hm2JMo2gXDt4SsvSwJsuGJJQVkaq_QgnTHa-C2HeAk",{"id":4264,"title":4265,"body":4266,"date":4637,"description":4638,"extension":1734,"meta":4639,"navigation":522,"path":4640,"seo":4641,"stem":4642,"tag":4010,"__hash__":4643},"updates_en\u002Fupdates\u002Flangdock-provider.md","Langdock as an AI Provider",{"type":7,"value":4267,"toc":4631},[4268,4281,4285,4293,4296,4300,4309,4316,4472,4478,4484,4488,4496,4502,4505,4510,4514,4517,4625,4628],[13,4269,4270,4271,1276,4275,4280],{},"The agent in the ",[17,4272,4274],{"href":4273},"\u002Fupdates\u002Fworkflow-engine","AI action",[17,4276,4279],{"href":4277,"rel":4278},"https:\u002F\u002Fopencode.ai",[44],"opencode",". Until now it ran through the providers of OpenCode itself, Zen or Go, with a key from the OpenCode console. Now we have added Langdock as a provider, a European alternative. Here too, one API key covers the models of several vendors, but every request of the agent stays in the selected region.",[57,4282,4284],{"id":4283},"what-langdock-is","What Langdock is",[13,4286,4287,4292],{},[17,4288,4291],{"href":4289,"rel":4290},"https:\u002F\u002Flangdock.com",[44],"Langdock"," is an AI gateway for European companies. It sits in front of several model vendors, among them OpenAI, Anthropic, Google, Meta, and Mistral. The team signs one contract and gets one API key. Behind that key, the models of all vendors are available.",[13,4294,4295],{},"The second reason for such a gateway is data residency. That is the guarantee that every request is processed in a fixed region, EU or US. This affects the agent directly, because its prompts contain code and data from the client project. Many client contracts require that such data does not leave the EU. With Langdock in the EU region, this also holds for the work of the agent. Often that is the condition under which the agent may work on the project at all.",[57,4297,4299],{"id":4298},"opencode-does-not-know-langdock","Opencode does not know Langdock",[13,4301,4302,4303,4308],{},"opencode resolves providers through the ",[17,4304,4307],{"href":4305,"rel":4306},"https:\u002F\u002Fmodels.dev",[44],"models.dev"," registry. For the previous providers, an environment variable with the API key is enough. opencode knows the rest itself. Langdock is not in that registry, so this path does not work.",[13,4310,4311,4312,4315],{},"Instead, Knecht generates an ",[90,4313,4314],{},"opencode.json"," in the checkout for every run. In that file, it declares Langdock as a custom provider, with the endpoint of the selected region and exactly the models this run may use. Shortened, the block looks like this:",[83,4317,4321],{"className":4318,"code":4319,"language":4320,"meta":88,"style":88},"language-json shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","{\n  \"provider\": {\n    \"langdock\": {\n      \"name\": \"Langdock\",\n      \"options\": {\n        \"baseURL\": \"https:\u002F\u002Fapi.langdock.com\u002Fopenai\u002Feu\u002Fv1\",\n        \"apiKey\": \"{env:LANGDOCK_API_KEY}\"\n      },\n      \"models\": { \"gpt-5.5\": {} }\n    }\n  }\n}\n","json",[90,4322,4323,4327,4342,4356,4376,4388,4409,4427,4432,4459,4464,4468],{"__ignoreMap":88},[93,4324,4325],{"class":95,"line":96},[93,4326,124],{"class":107},[93,4328,4329,4332,4335,4338,4340],{"class":95,"line":127},[93,4330,4331],{"class":107},"  \"",[93,4333,4334],{"class":186},"provider",[93,4336,4337],{"class":107},"\"",[93,4339,202],{"class":107},[93,4341,194],{"class":107},[93,4343,4344,4347,4350,4352,4354],{"class":95,"line":147},[93,4345,4346],{"class":107},"    \"",[93,4348,4349],{"class":190},"langdock",[93,4351,4337],{"class":107},[93,4353,202],{"class":107},[93,4355,194],{"class":107},[93,4357,4358,4361,4364,4366,4368,4370,4372,4374],{"class":95,"line":224},[93,4359,4360],{"class":107},"      \"",[93,4362,806],{"class":4363},"sbssI",[93,4365,4337],{"class":107},[93,4367,202],{"class":107},[93,4369,3312],{"class":107},[93,4371,4291],{"class":114},[93,4373,4337],{"class":107},[93,4375,643],{"class":107},[93,4377,4378,4380,4382,4384,4386],{"class":95,"line":239},[93,4379,4360],{"class":107},[93,4381,1060],{"class":4363},[93,4383,4337],{"class":107},[93,4385,202],{"class":107},[93,4387,194],{"class":107},[93,4389,4390,4393,4396,4398,4400,4402,4405,4407],{"class":95,"line":245},[93,4391,4392],{"class":107},"        \"",[93,4394,4395],{"class":137},"baseURL",[93,4397,4337],{"class":107},[93,4399,202],{"class":107},[93,4401,3312],{"class":107},[93,4403,4404],{"class":114},"https:\u002F\u002Fapi.langdock.com\u002Fopenai\u002Feu\u002Fv1",[93,4406,4337],{"class":107},[93,4408,643],{"class":107},[93,4410,4411,4413,4416,4418,4420,4422,4425],{"class":95,"line":275},[93,4412,4392],{"class":107},[93,4414,4415],{"class":137},"apiKey",[93,4417,4337],{"class":107},[93,4419,202],{"class":107},[93,4421,3312],{"class":107},[93,4423,4424],{"class":114},"{env:LANGDOCK_API_KEY}",[93,4426,3318],{"class":107},[93,4428,4429],{"class":95,"line":315},[93,4430,4431],{"class":107},"      },\n",[93,4433,4434,4436,4439,4441,4443,4445,4447,4450,4452,4454,4457],{"class":95,"line":349},[93,4435,4360],{"class":107},[93,4437,4438],{"class":4363},"models",[93,4440,4337],{"class":107},[93,4442,202],{"class":107},[93,4444,284],{"class":107},[93,4446,3312],{"class":107},[93,4448,4449],{"class":137},"gpt-5.5",[93,4451,4337],{"class":107},[93,4453,202],{"class":107},[93,4455,4456],{"class":107}," {}",[93,4458,312],{"class":107},[93,4460,4461],{"class":95,"line":384},[93,4462,4463],{"class":107},"    }\n",[93,4465,4466],{"class":95,"line":830},[93,4467,242],{"class":107},[93,4469,4470],{"class":95,"line":840},[93,4471,150],{"class":107},[13,4473,4474,4475,4477],{},"The key itself is not in the file. The config only points to an environment variable with ",[90,4476,4424],{},", and Knecht hands the value directly to the opencode process. So the key never lands in the checkout where the agent works.",[13,4479,4480,4481,4483],{},"One quirk of the gateway hides in the ",[90,4482,4395],{},". Langdock speaks a different API depending on the model. Claude models use the Anthropic-compatible one, all other models the OpenAI-compatible one. Knecht decides by the model name under which endpoint a model is declared in the config. In the picker, you just select a model. You never notice the split.",[57,4485,4487],{"id":4486},"the-setup","The setup",[13,4489,4490,4491,4495],{},"Everything lives under \"Settings\" → \"Agent\". There you select Langdock as the provider, and the region select appears next to it. The region applies to the whole instance, that is, to every request from every run. Then you save the Langdock API key. Like all provider keys, it is stored ",[17,4492,4494],{"href":4493},"\u002Fupdates\u002Fgh-auth-evolution","encrypted"," in the database. Afterwards it can only be replaced, not read again.",[13,4497,4498],{},[3517,4499],{"alt":4500,"src":4501},"The agent settings with Langdock as the provider, the region select, and the open model picker","\u002Fassets\u002Fsettings-agent-langdock.png",[13,4503,4504],{},"Once the key is saved, the model picker loads the list live from the Langdock workspace. It shows exactly the models that are enabled there, GPT and Claude models in the same picker. The default model and the optional subtask model, a smaller model for side tasks of the agent, work like with every other provider.",[10,4506,4507],{},[13,4508,4509],{},"When you switch the provider, Knecht clears the stored models, because the old names would not resolve in the catalog of the new provider. The AI action only runs again after a new default model is selected.",[57,4511,4513],{"id":4512},"mixing-models","Mixing models",[13,4515,4516],{},"Nothing changes in the AI action itself. The agent works in the running project as usual. It reads code, changes files, and executes commands, also with reasoning models like GPT-5.5. And because one key covers all models of the workspace, a workflow can still mix vendors through the per-step model override:",[83,4518,4520],{"className":3253,"code":4519,"language":3255,"meta":88,"style":88},"steps:\n  - type: ai\n    id: analyze\n    label: Analyze bug\n    model: gpt-5.5\n    prompt: Stelle den Bug aus {{ inputs.title }} nach und beschreibe die Ursache.\n  - type: ai\n    id: fix\n    label: Fix bug\n    model: claude-sonnet-4-5\n    prompt: Behebe die Ursache aus {{ steps.analyze.text }}.\n",[90,4521,4522,4528,4539,4549,4559,4569,4579,4589,4598,4607,4616],{"__ignoreMap":88},[93,4523,4524,4526],{"class":95,"line":96},[93,4525,3552],{"class":137},[93,4527,3340],{"class":107},[93,4529,4530,4532,4534,4536],{"class":95,"line":127},[93,4531,3345],{"class":107},[93,4533,1132],{"class":137},[93,4535,202],{"class":107},[93,4537,4538],{"class":114}," ai\n",[93,4540,4541,4544,4546],{"class":95,"line":147},[93,4542,4543],{"class":137},"    id",[93,4545,202],{"class":107},[93,4547,4548],{"class":114}," analyze\n",[93,4550,4551,4554,4556],{"class":95,"line":224},[93,4552,4553],{"class":137},"    label",[93,4555,202],{"class":107},[93,4557,4558],{"class":114}," Analyze bug\n",[93,4560,4561,4564,4566],{"class":95,"line":239},[93,4562,4563],{"class":137},"    model",[93,4565,202],{"class":107},[93,4567,4568],{"class":114}," gpt-5.5\n",[93,4570,4571,4574,4576],{"class":95,"line":245},[93,4572,4573],{"class":137},"    prompt",[93,4575,202],{"class":107},[93,4577,4578],{"class":114}," Stelle den Bug aus {{ inputs.title }} nach und beschreibe die Ursache.\n",[93,4580,4581,4583,4585,4587],{"class":95,"line":275},[93,4582,3345],{"class":107},[93,4584,1132],{"class":137},[93,4586,202],{"class":107},[93,4588,4538],{"class":114},[93,4590,4591,4593,4595],{"class":95,"line":315},[93,4592,4543],{"class":137},[93,4594,202],{"class":107},[93,4596,4597],{"class":114}," fix\n",[93,4599,4600,4602,4604],{"class":95,"line":349},[93,4601,4553],{"class":137},[93,4603,202],{"class":107},[93,4605,4606],{"class":114}," Fix bug\n",[93,4608,4609,4611,4613],{"class":95,"line":384},[93,4610,4563],{"class":137},[93,4612,202],{"class":107},[93,4614,4615],{"class":114}," claude-sonnet-4-5\n",[93,4617,4618,4620,4622],{"class":95,"line":830},[93,4619,4573],{"class":137},[93,4621,202],{"class":107},[93,4623,4624],{"class":114}," Behebe die Ursache aus {{ steps.analyze.text }}.\n",[13,4626,4627],{},"Both steps run through the same Langdock key and in the same region. The move to the EU gateway takes nothing away that you know from OpenCode Zen.",[1709,4629,4630],{},"html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":88,"searchDepth":127,"depth":127,"links":4632},[4633,4634,4635,4636],{"id":4283,"depth":127,"text":4284},{"id":4298,"depth":127,"text":4299},{"id":4486,"depth":127,"text":4487},{"id":4512,"depth":127,"text":4513},"2026-08-18","The agent now also runs through Langdock. One API key covers GPT and Claude models, and every request stays in the EU or in the US.",{},"\u002Fupdates\u002Flangdock-provider",{"title":4265,"description":4638},"updates\u002Flangdock-provider","ffzNE_6I8Vv_ovx-IxV_j_bz6FkK4oYJipCULDi6tEI",{"id":4645,"title":25,"body":4646,"date":4761,"description":4762,"extension":1734,"meta":4763,"navigation":522,"path":24,"seo":4764,"stem":4765,"tag":4010,"__hash__":4766},"updates_en\u002Fupdates\u002Fsessions-and-mentions.md",{"type":7,"value":4647,"toc":4755},[4648,4654,4658,4670,4676,4679,4685,4688,4694,4697,4701,4704,4710,4713,4727,4735,4739,4748,4752],[13,4649,4650,4651,4653],{},"Until now, everything in Knecht flowed in one direction. A ",[17,4652,484],{"href":4273}," fired, a workflow ran, and the result sat in the dashboard. Anyone waiting for an answer in the GitHub issue saw nothing there. With this update, Knecht works where the work is reported. It replies and labels directly in the issue thread, and a mention in a comment sends it back to work.",[57,4655,4657],{"id":4656},"from-bug-report-to-pr","From bug report to PR",[13,4659,4660,4661,4665,4666,4669],{},"This is what it looks like on our Craft test project. Someone files an issue saying the site has no dark mode. A trigger starts the \"Classify Issue\" workflow. Knecht boots the site in its ",[17,4662,4664],{"href":4663},"\u002Fupdates\u002Fsandbox-rollback","preview environment",", reproduces the report, and finds out that dark mode was simply never implemented. It applies the existing ",[90,4667,4668],{},"enhancement"," label and answers the reporter in the thread.",[13,4671,4672],{},[3517,4673],{"alt":4674,"src":4675},"GitHub issue with a dark mode bug report, below it the knecht-works app has applied the enhancement label","\u002Fassets\u002Fissue-creation-with-label.png",[13,4677,4678],{},"The reply contains the findings and a concrete plan for the implementation, with the preview URL at the end.",[13,4680,4681],{},[3517,4682],{"alt":4683,"src":4684},"Comment by the knecht-works app in the issue with findings and an implementation plan, below it a comment by the team member with a mention and an eyes reaction","\u002Fassets\u002Fissue-follow-up.png",[13,4686,4687],{},"A team member reads the analysis and replies with a mention, roughly \"@knecht-works, implement it and open a PR\". Knecht confirms right away with an eyes reaction and continues working, in the same environment and the same conversation as the triage. So it already knows the findings and the plan. A few minutes later the answer is in the thread, with the opened PR and the preview URL.",[13,4689,4690],{},[3517,4691],{"alt":4692,"src":4693},"Reply by the knecht-works app in the issue with a summary of the two commits, at the end the preview URL and the PR link","\u002Fassets\u002Fissue-enhancement-finished.png",[13,4695,4696],{},"The fix can build on the triage because of the biggest change in this update, sessions.",[57,4698,4700],{"id":4699},"one-session-per-issue","One session per issue",[13,4702,4703],{},"Until now, every workflow execution was its own world, with its own checkout, its own environment, and its own agent conversation. While building the reply loop it became clear that this is the wrong unit, because the environment and the context belong to the issue, not to a single execution. So there are sessions now. A session belongs to exactly one issue or PR and holds its checkout, its environment, and one shared agent conversation. Inside it run the runs, that is, individual workflow executions, and follow-ups, that is, individual messages to the agent.",[13,4705,4706],{},[3517,4707],{"alt":4708,"src":4709},"Project page in the Knecht dashboard with the run list grouped by issues, under the dark mode issue four runs from the triage to two mentions","\u002Fassets\u002Fknecht-project-triggers-in-action.png",[13,4711,4712],{},"The project page and the runs overview now group runs by session. In the screenshot, four runs belong to the dark mode issue, from \"Classify Issue\" to the two mentions, all in the same environment. In detail this means:",[1688,4714,4715,4718,4721,4724],{},[1691,4716,4717],{},"If two triggers fire on the same issue, they run one after the other in the session, never in parallel.",[1691,4719,4720],{},"If the issue is closed, the session closes too. A reopen revives it, as long as its environment still exists.",[1691,4722,4723],{},"When archiving an environment, Knecht now also saves the agent's conversation, a few megabytes. It survives an archive and restore cycle.",[1691,4725,4726],{},"Events without an issue or PR, that is, push, schedule, and manual start, behave as before and get a session holding a single run.",[13,4728,4729,4730,4734],{},"If an environment is torn down completely, the conversation is gone. The agent then does not silently start from zero. It reads the GitHub thread, the results of the previous actions, and the ",[17,4731,4733],{"href":4732},"\u002Fupdates\u002Fagent-memory","project memory",", and says in the thread that it is starting fresh. Existing installations do not have to do anything for this, the update automatically converts existing runs and environments to sessions on startup.",[57,4736,4738],{"id":4737},"the-cloudflare-example","The Cloudflare example",[13,4740,4741,4742,4747],{},"Cloudflare showed in August 2026 that issue triage can be automated end to end, on the repository of the Astro framework. Their ",[17,4743,4746],{"href":4744,"rel":4745},"https:\u002F\u002Fblog.cloudflare.com\u002Fastro-issue-triage\u002F",[44],"triage system"," reproduces every report, diagnoses the cause, and builds the fix. The open issues dropped from over 200 to about 30 with it. It is its own piece of software though, built by a team at Cloudflare for exactly this repository. In Knecht, the same flow is a workflow that you put on your own projects, and the session brings the booted site with it.",[57,4749,4751],{"id":4750},"what-is-next","What is next",[13,4753,4754],{},"Next up is Jira. The concepts stay the same, a ticket gets its session with an environment and a conversation, and replies and mentions work in the ticket just like in the issue thread.",{"title":88,"searchDepth":127,"depth":127,"links":4756},[4757,4758,4759,4760],{"id":4656,"depth":127,"text":4657},{"id":4699,"depth":127,"text":4700},{"id":4737,"depth":127,"text":4738},{"id":4750,"depth":127,"text":4751},"2026-08-17","Every issue gets its own session with an environment and a conversation, the agent replies and labels directly in the thread, and a mention sends it back to work.",{},{"title":25,"description":4762},"updates\u002Fsessions-and-mentions","vn8f9WoEsToibPK-tkR5TXGfNKiZ9MgaWYL2b1RHoik",{"id":4768,"title":4769,"body":4770,"date":5009,"description":5010,"extension":1734,"meta":5011,"navigation":522,"path":4732,"seo":5012,"stem":5013,"tag":4010,"__hash__":5014},"updates_en\u002Fupdates\u002Fagent-memory.md","The Agent Gets a Memory per Project",{"type":7,"value":4771,"toc":5003},[4772,4782,4786,4793,4809,4829,4832,4836,4848,4948,4957,4963,4967,4970,4973,4987,4993,4997,5000],[13,4773,4774,4775,4777,4778,4781],{},"The ",[17,4776,4274],{"href":4273}," starts a fresh ",[17,4779,4780],{"href":4663},"sandbox"," for every run, with its own checkout of the project. Until now the agent knew nothing about earlier runs. What it had found out about a project was gone afterwards, and the next run had to explore it again. Now Knecht keeps notes per project. The agent writes them itself and finds them again in the next run.",[57,4783,4785],{"id":4784},"every-run-started-at-zero","Every run started at zero",[13,4787,4788,4789,4792],{},"In August 2026 we looked at where slow runs lose their time. A large part was rediscovery. A one-line change to a ",[90,4790,4791],{},"font-weight"," took 280 seconds on a project that already had dozens of runs behind it. Around 160 of those seconds went to a subagent finding out where the styles live and how the project is built, facts that earlier runs already knew.",[13,4794,4795,4798,4799,4804,4805,4808],{},[17,4796,4279],{"href":4277,"rel":4797},[44],", the agent behind the AI action, only knows static ",[17,4800,4803],{"href":4801,"rel":4802},"https:\u002F\u002Fopencode.ai\u002Fdocs\u002Frules\u002F",[44],"rules files"," like an ",[90,4806,4807],{},"AGENTS.md"," in the repository. A human writes that file by hand, the agent does not add anything to it. There are community plugins for learned memory, but they do not fit Knecht.",[1688,4810,4811,4814],{},[1691,4812,4813],{},"Everything a plugin writes to disk lands in the throwaway checkout of the run and is gone afterwards. Knecht would have to build the persistence outside the run anyway.",[1691,4815,4816,4817,4822,4823,4828],{},"The best-known plugin, ",[17,4818,4821],{"href":4819,"rel":4820},"https:\u002F\u002Fgithub.com\u002Ftickernelz\u002Fopencode-mem",[44],"opencode-mem",", only saves its memories when the session has been quiet for a while. That is the pause in which a human in a chat is not typing. But Knecht starts opencode ",[17,4824,4827],{"href":4825,"rel":4826},"https:\u002F\u002Fopencode.ai\u002Fdocs\u002Fcli\u002F",[44],"non-interactively",", and that process exits right after its answer. The pause never comes, so the plugin would never save.",[13,4830,4831],{},"So we built this directly into Knecht, as a small store per project on the host, outside every sandbox.",[57,4833,4835],{"id":4834},"an-index-and-topic-files","An index and topic files",[13,4837,4838,4839,4842,4843,4847],{},"The memory consists of an index file ",[90,4840,4841],{},"MEMORY.md"," and any number of topic files. After a few runs on a ",[17,4844,3732],{"href":4845,"rel":4846},"https:\u002F\u002Fcraftcms.com",[44]," project it can look like this:",[4849,4850,4851,4872,4908],"code-tree",{"default-value":4841},[83,4852,4855],{"className":4853,"code":4854,"filename":4841,"language":1734,"meta":88,"style":88},"language-md shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","- Styles: Tailwind, tokens, build step, see styles.md\n- Build and tests: commands and pitfalls, see build.md\n",[90,4856,4857,4865],{"__ignoreMap":88},[93,4858,4859,4862],{"class":95,"line":96},[93,4860,4861],{"class":107},"-",[93,4863,4864],{"class":103}," Styles: Tailwind, tokens, build step, see styles.md\n",[93,4866,4867,4869],{"class":95,"line":127},[93,4868,4861],{"class":107},[93,4870,4871],{"class":103}," Build and tests: commands and pitfalls, see build.md\n",[83,4873,4876],{"className":4853,"code":4874,"filename":4875,"language":1734,"meta":88,"style":88},"Tailwind 4, tokens live in src\u002Fcss\u002Fconfig.css.\nFont sizes and weights only via text-* utilities, no raw values.\nAfter CSS changes run `ddev npm run build`,\ntemplates load the built file from web\u002Fdist\u002F.\n","styles.md",[90,4877,4878,4883,4888,4903],{"__ignoreMap":88},[93,4879,4880],{"class":95,"line":96},[93,4881,4882],{"class":103},"Tailwind 4, tokens live in src\u002Fcss\u002Fconfig.css.\n",[93,4884,4885],{"class":95,"line":127},[93,4886,4887],{"class":103},"Font sizes and weights only via text-* utilities, no raw values.\n",[93,4889,4890,4893,4896,4899,4901],{"class":95,"line":147},[93,4891,4892],{"class":103},"After CSS changes run ",[93,4894,4895],{"class":107},"`",[93,4897,4898],{"class":114},"ddev npm run build",[93,4900,4895],{"class":107},[93,4902,643],{"class":103},[93,4904,4905],{"class":95,"line":224},[93,4906,4907],{"class":103},"templates load the built file from web\u002Fdist\u002F.\n",[83,4909,4912],{"className":4853,"code":4910,"filename":4911,"language":1734,"meta":88,"style":88},"Build: `ddev npm run build`\nTests: `ddev php vendor\u002Fbin\u002Fphpunit`\nThe build needs DDEV running, plain npm on the host\nfails on the node version.\n","build.md",[90,4913,4914,4926,4938,4943],{"__ignoreMap":88},[93,4915,4916,4919,4921,4923],{"class":95,"line":96},[93,4917,4918],{"class":103},"Build: ",[93,4920,4895],{"class":107},[93,4922,4898],{"class":114},[93,4924,4925],{"class":107},"`\n",[93,4927,4928,4931,4933,4936],{"class":95,"line":127},[93,4929,4930],{"class":103},"Tests: ",[93,4932,4895],{"class":107},[93,4934,4935],{"class":114},"ddev php vendor\u002Fbin\u002Fphpunit",[93,4937,4925],{"class":107},[93,4939,4940],{"class":95,"line":147},[93,4941,4942],{"class":103},"The build needs DDEV running, plain npm on the host\n",[93,4944,4945],{"class":95,"line":224},[93,4946,4947],{"class":103},"fails on the node version.\n",[13,4949,4950,4951,4956],{},"The index holds one line per topic and is merged into the instructions on every agent call, so it is always in context. To keep that cheap, it has a hard limit of 2 KB. The agent only reads a topic file when the index points at something relevant. Until then the topic files cost no context. The pattern comes from Claude Code, whose ",[17,4952,4955],{"href":4953,"rel":4954},"https:\u002F\u002Fcode.claude.com\u002Fdocs\u002Fen\u002Fmemory",[44],"auto-memory"," is built the same way.",[13,4958,4959,4960,4962],{},"The agent maintains the memory itself, following rules in the ",[90,4961,4807],{}," that Knecht puts into every sandbox. It should rewrite notes instead of appending, delete outdated notes, and record corrections that come in through the follow-up chat on the run page. If you write \"no, we do not use utility classes\" there once, you should not have to write it a second time. And the agent should briefly check its own notes before building on them, because the project can have changed since the last run.",[57,4964,4966],{"id":4965},"copy-instead-of-mount","Copy instead of mount",[13,4968,4969],{},"Before every agent call Knecht copies the notes into the checkout of the run. After the call it copies them back to the host. The copy back also happens for failed steps, because the notes hold facts about the project, not a run status.",[13,4971,4972],{},"A mount into the sandbox would have been simpler. But with the copy, every run works only on its own state, and Knecht has one place during the copy back where it can check what the agent wrote.",[1688,4974,4975,4984],{},[1691,4976,4977,4978,4983],{},"Only plain Markdown files at the top level are accepted, no ",[17,4979,4982],{"href":4980,"rel":4981},"https:\u002F\u002Fman7.org\u002Flinux\u002Fman-pages\u002Fman7\u002Fsymlink.7.html",[44],"symlinks",", no subfolders, no hidden files.",[1691,4985,4986],{},"The index may be at most 2 KB, all files together at most 64 KB. If the copy is larger, Knecht does not accept it at all. The last valid state stays, and one line about it appears in the run log.",[13,4988,4989,4990,4992],{},"Knecht discards oversized copies instead of trimming them, because the agent would not notice a trim and would then keep working with incomplete notes. With the old state it can clean up itself in the next run. The rules for that are in the ",[90,4991,4807],{},". Knecht still checks the limits itself, so the context does not grow when the model ignores the rules.",[57,4994,4996],{"id":4995},"what-is-still-missing","What is still missing",[13,4998,4999],{},"There is no view in the project settings yet that shows what Knecht has remembered. You would want to read the notes there and also correct them. It is also open whether Knecht should at some point summarize an oversized copy instead of discarding it. That is only worth building if discards turn out to be frequent in practice.",[1709,5001,5002],{},"html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}",{"title":88,"searchDepth":127,"depth":127,"links":5004},[5005,5006,5007,5008],{"id":4784,"depth":127,"text":4785},{"id":4834,"depth":127,"text":4835},{"id":4965,"depth":127,"text":4966},{"id":4995,"depth":127,"text":4996},"2026-08-14","Until now the agent explored the project again in every run. Now Knecht remembers per project what the agent has learned.",{},{"title":4769,"description":5010},"updates\u002Fagent-memory","sweXAbMqWwIG7q9B0qMUsONW3wvcn1IMCuAZxb2ERxA",{"id":5016,"title":5017,"body":5018,"date":5283,"description":5284,"extension":1734,"meta":5285,"navigation":522,"path":5286,"seo":5287,"stem":5288,"tag":4261,"__hash__":5289},"updates_en\u002Fupdates\u002Flicense-decision.md","Why Knecht Is Not Open Source",{"type":7,"value":5019,"toc":5275},[5020,5028,5032,5041,5044,5048,5051,5084,5087,5091,5094,5181,5188,5207,5211,5214,5225,5234,5243,5247,5262,5266],[13,5021,4774,5022,5027],{},[17,5023,5026],{"href":5024,"rel":5025},"https:\u002F\u002Fgithub.com\u002Fknecht-works\u002Fknecht-cloud",[44],"Knecht repo"," was public on GitHub from the start, but it had no license. We changed that now. Knecht is under the Functional Source License, short FSL. This post explains why a license was necessary, why we did not select MIT, and what you can officially do with the code.",[57,5029,5031],{"id":5030},"without-a-license-nothing-is-permitted","Without a license, nothing is permitted",[13,5033,5034,5035,5040],{},"Public and free to use are two different things. Code has copyright protection, like a text or a photo. Without an explicit license, the default rule of copyright law applies. All rights are reserved. ",[17,5036,5039],{"href":5037,"rel":5038},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Frepositories\u002Fmanaging-your-repositorys-settings-and-features\u002Fcustomizing-your-repository\u002Flicensing-a-repository",[44],"GitHub states this itself",". You can only view a public repo without a license and fork it on the platform. You cannot download the code, change it, or use it in production.",[13,5042,5043],{},"A repo without a license is therefore more restrictive than the strictest license. That was not our intention. We simply did not decide the license question yet. Beta users who host Knecht themselves had our permission, but only in spoken form, not in writing.",[57,5045,5047],{"id":5046},"the-dilemma","The dilemma",[13,5049,5050],{},"We had two requirements for the license, and at first sight they conflict. The code must stay public, and everyone must be able to host, read, and change Knecht for free. At the same time, Knecht must be able to make money one day, so the project can operate permanently. How exactly is open. Maybe a hosted version, maybe paid extra functions for teams.",[13,5052,5053,5054,5059,5060,5065,5066,5071,5072,5077,5078,5083],{},"A permissive license like ",[17,5055,5058],{"href":5056,"rel":5057},"https:\u002F\u002Fopensource.org\u002Flicense\u002Fmit",[44],"MIT"," or Apache does not fit these goals. Permissive means that everyone can do everything. A company could take Knecht and sell it as its own product. Elasticsearch showed where this leads. It had the Apache license, AWS sold it as its own managed service, and Elastic got nothing. In 2021 ",[17,5061,5064],{"href":5062,"rel":5063},"https:\u002F\u002Fwww.elastic.co\u002Fblog\u002Flicensing-change",[44],"Elastic moved to a more restrictive license",". AWS forked the last free version and now operates it as ",[17,5067,5070],{"href":5068,"rel":5069},"https:\u002F\u002Fopensearch.org\u002F",[44],"OpenSearch",". Terraform (fork: ",[17,5073,5076],{"href":5074,"rel":5075},"https:\u002F\u002Fopentofu.org\u002F",[44],"OpenTofu",") and Redis (fork: ",[17,5079,5082],{"href":5080,"rel":5081},"https:\u002F\u002Fvalkey.io\u002F",[44],"Valkey",") went through the same cycle.",[13,5085,5086],{},"These stories share one point. The license change came late, after a community grew on the free license. A license change only applies to new versions. Code that was published under a permissive license stays permissive forever. And after the change, the community understandably supports the fork. If a limit is necessary, it must exist from the start.",[57,5088,5090],{"id":5089},"the-candidates","The candidates",[13,5092,5093],{},"We examined five licenses closely:",[3154,5095,5096,5112],{},[3157,5097,5098],{},[3160,5099,5100,5103,5106,5109],{},[3163,5101,5102],{},"License",[3163,5104,5105],{},"Model",[3163,5107,5108],{},"Forbids",[3163,5110,5111],{},"Becomes open source",[3172,5113,5114,5128,5141,5155,5168],{},[3160,5115,5116,5119,5122,5125],{},[3177,5117,5118],{},"MIT \u002F Apache",[3177,5120,5121],{},"open source, permissive",[3177,5123,5124],{},"nothing",[3177,5126,5127],{},"it already is",[3160,5129,5130,5133,5136,5139],{},[3177,5131,5132],{},"AGPL",[3177,5134,5135],{},"open source, copyleft",[3177,5137,5138],{},"nothing, but you must publish your own changes",[3177,5140,5127],{},[3160,5142,5143,5146,5149,5152],{},[3177,5144,5145],{},"BSL",[3177,5147,5148],{},"source available",[3177,5150,5151],{},"what the vendor defines",[3177,5153,5154],{},"after up to four years",[3160,5156,5157,5160,5162,5165],{},[3177,5158,5159],{},"ELv2",[3177,5161,5148],{},[3177,5163,5164],{},"operation as a managed service",[3177,5166,5167],{},"never",[3160,5169,5170,5173,5175,5178],{},[3177,5171,5172],{},"FSL",[3177,5174,5148],{},[3177,5176,5177],{},"competitive products",[3177,5179,5180],{},"after two years",[13,5182,4774,5183,5187],{},[17,5184,5132],{"href":5185,"rel":5186},"https:\u002F\u002Fwww.gnu.org\u002Flicenses\u002Fagpl-3.0.html",[44]," is the one true open source license that protects a business model. If you operate AGPL software as a web service or build it into a product, you must publish your own code. This blocks competitors reliably. But it also blocks the wrong people. Many companies ban AGPL software completely, because their legal team does not want to examine the publication duty in each case. Agencies with careful customers would be affected most, and Knecht is made exactly for these people.",[13,5189,5190,5191,5195,5196,5200,5201,5206],{},"BSL, ELv2, and FSL use the same model, and the model is called source available. The code is public and free to use, but only the vendor can sell it. The ",[17,5192,5145],{"href":5193,"rel":5194},"https:\u002F\u002Fmariadb.com\u002Fbsl11\u002F",[44]," (from MariaDB) requires that the vendor writes the permitted use himself. Therefore each BSL text is different, and you must examine each one. Even the question if you can host the software for free depends on the vendor. The ",[17,5197,5159],{"href":5198,"rel":5199},"https:\u002F\u002Fwww.elastic.co\u002Flicensing\u002Felastic-license",[44]," (from Elastic) is simple, but it never becomes open source. The FSL (",[17,5202,5205],{"href":5203,"rel":5204},"https:\u002F\u002Fblog.sentry.io\u002Fintroducing-the-functional-source-license-freedom-without-free-riding\u002F",[44],"from Sentry",") is the newest of the three. It is one page of text, it has fixed rules, and it has a built-in conversion to open source.",[57,5208,5210],{"id":5209},"what-the-fsl-permits","What the FSL permits",[13,5212,5213],{},"For most users the license changes nothing. It permits exactly what Knecht users do already:",[1688,5215,5216,5219,5222],{},[1691,5217,5218],{},"host Knecht yourself and use it in production, also commercially, also for customer projects",[1691,5220,5221],{},"read, change, and fork the code",[1691,5223,5224],{},"offer services around Knecht, for example setup and operation for a customer",[13,5226,5227,5228,5233],{},"The license forbids one thing. You cannot offer Knecht to others as your own commercial product. For example, you cannot sell Knecht as a hosted service or distribute Knecht under a different name. The ",[17,5229,5232],{"href":5230,"rel":5231},"https:\u002F\u002Ffsl.software\u002F",[44],"license text"," calls this Competing Use.",[13,5235,5236,5237,5242],{},"One clause made us select the FSL in the end. Two years after its release, each published version automatically gets the ",[17,5238,5241],{"href":5239,"rel":5240},"https:\u002F\u002Fwww.apache.org\u002Flicenses\u002FLICENSE-2.0",[44],"Apache 2.0 license",", which is true open source. This rule is irrevocable in the license text. We could not cancel it. If Knecht stops one day, the complete code becomes free after two years at the latest. Nobody here builds on software that someone can take away permanently.",[57,5244,5246],{"id":5245},"not-open-source","Not open source",[13,5248,5249,5250,5255,5256,5261],{},"People often call every public repo open source. But open source is a defined term. The ",[17,5251,5254],{"href":5252,"rel":5253},"https:\u002F\u002Fopensource.org\u002Fosd",[44],"definition of the Open Source Initiative"," requires that a license does not exclude any field of use, and this includes competition. The FSL does exactly that with its ban on Competing Use. Therefore Knecht is not open source. The correct terms are source available or ",[17,5257,5260],{"href":5258,"rel":5259},"https:\u002F\u002Ffair.io\u002F",[44],"Fair Source",". Fair Source is the initiative in which Sentry and others collected this model.",[57,5263,5265],{"id":5264},"next-steps","Next steps",[13,5267,5268,5269,5274],{},"One point is still on the list. Before we merge the first external pull request, we will set up a CLA, a ",[17,5270,5273],{"href":5271,"rel":5272},"https:\u002F\u002Fde.wikipedia.org\u002Fwiki\u002FContributor_License_Agreement",[44],"Contributor License Agreement",". It is a short agreement. Contributors give us the right to license their contribution together with the rest of the code. Without it, the rights to the code would spread across many persons. Then we would have to negotiate each later license model with each single contributor.",{"title":88,"searchDepth":127,"depth":127,"links":5276},[5277,5278,5279,5280,5281,5282],{"id":5030,"depth":127,"text":5031},{"id":5046,"depth":127,"text":5047},{"id":5089,"depth":127,"text":5090},{"id":5209,"depth":127,"text":5210},{"id":5245,"depth":127,"text":5246},{"id":5264,"depth":127,"text":5265},"2026-08-09","Knecht now has a license, the Functional Source License. This post explains what you can do with the code, why we did not select MIT, and how each version becomes open source after two years.",{},"\u002Fupdates\u002Flicense-decision",{"title":5017,"description":5284},"updates\u002Flicense-decision","45tf-O_lO750PUG_AWjYxfE2V2JWI9Kx2fusHl50GSg",{"id":5291,"title":5292,"body":5293,"date":5441,"description":5442,"extension":1734,"meta":5443,"navigation":522,"path":5444,"seo":5445,"stem":5446,"tag":5447,"__hash__":5448},"updates_en\u002Fupdates\u002Fweb-terminal-vscode.md","A Terminal and VS Code for Each Run",{"type":7,"value":5294,"toc":5434},[5295,5302,5306,5309,5313,5317,5320,5334,5339,5343,5346,5405,5408,5412,5421,5425,5428,5431],[13,5296,5297,5298,5301],{},"A run is a single execution of a ",[17,5299,5300],{"href":4273},"workflow"," on a project, with its own environment on the server. Before, the dashboard was the only way to this environment. You could send instructions to the agent and view the preview, but you could not enter the environment yourself. To read a log or to test a command, you had to ask the agent. Now the run page has three direct ways in.",[57,5303,5305],{"id":5304},"the-terminal","The terminal",[13,5307,5308],{},"The most direct way into a run is a shell. It exists in two variants, in the browser for each dashboard member, and over SSH for people who already have access to the server. The video shows both.",[3540,5310],{"caption":5311,"src":5312},"The terminal in the browser and the ready SSH command","\u002Fassets\u002Fknecht-ssh-showcase.webm",[62,5314,5316],{"id":5315},"in-the-browser","In the browser",[13,5318,5319],{},"A click on \"Terminal\" opens a shell in the container of the run. The shell opens as a window directly on the run page. You type in the same browser tab in which you view the run. You do not need a terminal program or an SSH client on your own computer.",[1688,5321,5322,5325,5328,5331],{},[1691,5323,5324],{},"It works for each dashboard member, without any access to the server.",[1691,5326,5327],{},"The shell runs in the same container as the agent. Composer, npm, and Git are there, and they behave like in the real project.",[1691,5329,5330],{},"No additional port opens. The terminal uses the same HTTPS connection as the dashboard.",[1691,5332,5333],{},"If a run has multiple containers, for example its own database, the terminal shows one tab per service.",[10,5335,5336],{},[13,5337,5338],{},"Knecht stops the environment of a run automatically when nobody uses it for a while, so it does not use RAM. Work in the terminal counts as use. The environment stays on while you type in it.",[62,5340,5342],{"id":5341},"over-ssh","Over SSH",[13,5344,5345],{},"If you prefer your own terminal, the terminal window shows a ready SSH command that you can copy. Paste it on your own machine, and you land in exactly the same container.",[83,5347,5351],{"className":5348,"code":5349,"language":5350,"meta":88,"style":88},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","ssh -t knecht@my-server.com docker exec -it -u 1000:1000 -w \u002Fvar\u002Fwww\u002Fhtml -e HOME=\u002Fhome\u002Fnode -e USER=node ddev-knecht-run-4-web bash -l\n","bash",[90,5352,5353],{"__ignoreMap":88},[93,5354,5355,5358,5361,5364,5367,5370,5373,5376,5379,5382,5385,5388,5391,5393,5396,5399,5402],{"class":95,"line":96},[93,5356,5357],{"class":190},"ssh",[93,5359,5360],{"class":114}," -t",[93,5362,5363],{"class":114}," knecht@my-server.com",[93,5365,5366],{"class":114}," docker",[93,5368,5369],{"class":114}," exec",[93,5371,5372],{"class":114}," -it",[93,5374,5375],{"class":114}," -u",[93,5377,5378],{"class":114}," 1000:1000",[93,5380,5381],{"class":114}," -w",[93,5383,5384],{"class":114}," \u002Fvar\u002Fwww\u002Fhtml",[93,5386,5387],{"class":114}," -e",[93,5389,5390],{"class":114}," HOME=\u002Fhome\u002Fnode",[93,5392,5387],{"class":114},[93,5394,5395],{"class":114}," USER=node",[93,5397,5398],{"class":114}," ddev-knecht-run-4-web",[93,5400,5401],{"class":114}," bash",[93,5403,5404],{"class":114}," -l\n",[13,5406,5407],{},"Knecht does not operate its own SSH server, and it does not manage keys. The command uses the SSH access to the server that already exists. You store the SSH address of the server once in the settings, nothing more. This way is therefore only for people who already have access to the server. All others use the web terminal.",[57,5409,5411],{"id":5410},"vs-code-in-the-browser","VS Code in the browser",[13,5413,5414,5415,5420],{},"VS Code runs directly in the run. A click on \"Open in VS Code\" on the run page opens the IDE in a new browser tab. The base is ",[17,5416,5419],{"href":5417,"rel":5418},"https:\u002F\u002Fgithub.com\u002Fgitpod-io\u002Fopenvscode-server",[44],"openvscode-server",", the open source build of VS Code for the browser.",[3540,5422],{"caption":5423,"src":5424},"VS Code opens the run in the browser","\u002Fassets\u002Fknecht-vs-code.webm",[13,5426,5427],{},"The IDE works directly on the project in the run, not on a copy. A saved change is immediately in the active project. It is the same code that the preview and the agent see. And because each run is a full Git clone, commits and branches work normally in the IDE and in the terminal.",[13,5429,5430],{},"You do not need a local setup. The IDE runs in the browser for each dashboard member. Like the previews, you can only reach it with a Knecht login.",[1709,5432,5433],{},"html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":88,"searchDepth":127,"depth":127,"links":5435},[5436,5440],{"id":5304,"depth":127,"text":5305,"children":5437},[5438,5439],{"id":5315,"depth":147,"text":5316},{"id":5341,"depth":147,"text":5342},{"id":5410,"depth":127,"text":5411},"2026-07-27","A terminal in the browser, a ready SSH command, and VS Code without a local setup. You can now open each run directly and continue the work.",{},"\u002Fupdates\u002Fweb-terminal-vscode",{"title":5292,"description":5442},"updates\u002Fweb-terminal-vscode","Dashboard","BRM8yZXp0UyoZXhYSapqlx4QFOpYMsEZ64j334XDXAE",{"id":5450,"title":5451,"body":5452,"date":5735,"description":5736,"extension":1734,"meta":5737,"navigation":522,"path":4663,"seo":5738,"stem":5739,"tag":1739,"__hash__":5740},"updates_en\u002Fupdates\u002Fsandbox-rollback.md","Back to the Shared Daemon, One Preview for 180 MB",{"type":7,"value":5453,"toc":5724},[5454,5464,5479,5483,5497,5505,5508,5512,5515,5535,5538,5542,5546,5549,5562,5566,5574,5589,5592,5596,5611,5619,5623,5626,5697,5700,5714,5717,5721],[10,5455,5456],{},[13,5457,5458,5459,5463],{},"This post continues ",[17,5460,5462],{"href":5461},"\u002Fupdates\u002Fdashboard-architecture","From the shared Docker daemon to a sandbox per run",". That post explains why we selected a separate sandbox per run. This post explains why we removed it again.",[13,5465,5466,5467,5472,5473,5478],{},"The last architecture post ended with a clear plan. Each run gets its own ",[17,5468,5471],{"href":5469,"rel":5470},"https:\u002F\u002Fgithub.com\u002Fnestybox\u002Fsysbox",[44],"Sysbox"," sandbox with its own ",[17,5474,5477],{"href":5475,"rel":5476},"https:\u002F\u002Fdocs.docker.com\u002Fget-started\u002Fdocker-overview\u002F#docker-architecture",[44],"Docker daemon",". We built it, and it worked. Still, we removed the sandbox again, because two problems could wait no longer. We postponed both problems on purpose before.",[57,5480,5482],{"id":5481},"the-cost","The cost",[13,5484,5485,5486,5491,5492,5496],{},"A Sysbox sandbox is basically a small operating system with ",[17,5487,5490],{"href":5488,"rel":5489},"https:\u002F\u002Fsystemd.io",[44],"systemd",", its own Docker daemon, and the complete ",[17,5493,3133],{"href":5494,"rel":5495},"https:\u002F\u002Fddev.com",[44]," stack on top. We knew from the start that this uses many resources. We thought we could control the cost later, but we could not.",[1688,5498,5499,5502],{},[1691,5500,5501],{},"One active preview used about 3 GB of RAM in our measurements. With 20 parallel previews, that would be 60 GB, only for sandboxes.",[1691,5503,5504],{},"Each sandbox needs its own copy of all DDEV images, and that is 1-2 GB of disk per preview. A registry cache made the downloads faster, but it did not change the disk use.",[13,5506,5507],{},"The goal of Knecht is that an agency operates all its projects on one affordable server. An architecture with 3 GB per preview is the wrong base for this goal, no matter how clean its isolation is.",[57,5509,5511],{"id":5510},"what-the-sandbox-solved","What the sandbox solved",[13,5513,5514],{},"As a reminder, the last post gave two reasons for the sandbox.",[1688,5516,5517,5526],{},[1691,5518,5519,5520,5525],{},"On the shared daemon, there was no isolation between runs. The fixed ports of the ",[17,5521,5524],{"href":5522,"rel":5523},"https:\u002F\u002Fdocs.ddev.com\u002Fen\u002Fstable\u002Fusers\u002Fusage\u002Farchitecture\u002F",[44],"DDEV router"," collided. Therefore we had to remove the router and change the DDEV config.",[1691,5527,5528,5529,5534],{},"The agent executes external code, and a process that reaches the ",[17,5530,5533],{"href":5531,"rel":5532},"https:\u002F\u002Fdocs.docker.com\u002Fengine\u002Fsecurity\u002F#docker-daemon-attack-surface",[44],"Docker socket"," of the host controls the complete server.",[13,5536,5537],{},"The sandbox solved both problems at once. But it was the most thorough solution, not the only one. We could only remove it after we examined both problems again, one by one.",[57,5539,5541],{"id":5540},"the-new-solutions","The new solutions",[62,5543,5545],{"id":5544},"no-socket-in-the-run","No socket in the run",[13,5547,5548],{},"The problem is the Docker socket. The agent executes external code. When the socket is in the environment of the agent, a hijacked agent controls the complete server. The sandbox therefore gave each run its own daemon, so the socket of the host stayed out of reach.",[13,5550,5551,5552,5555,5556,5561],{},"Our new solution starts earlier. The run gets no Docker access at all. Knecht starts DDEV on the host side. The agent and all project commands, for example ",[90,5553,5554],{},"composer install",", run only in the web container of the project. An agent that an attacker hijacks with ",[17,5557,5560],{"href":5558,"rel":5559},"https:\u002F\u002Fsimonwillison.net\u002Fseries\u002Fprompt-injection\u002F",[44],"prompt injection"," then sits in a normal container with its own network and limited resources. It finds no socket and no daemon there.",[62,5563,5565],{"id":5564},"rename-instead-of-isolate","Rename instead of isolate",[13,5567,5568,5569,36],{},"The port collisions came only from the router. The router is the reverse proxy that DDEV puts in front of all projects, and it binds ports 80 and 443 on the host. The router stays removed. Instead, the preview proxy connects to the web container of each run directly over the ",[17,5570,5573],{"href":5571,"rel":5572},"https:\u002F\u002Fdocs.docker.com\u002Fengine\u002Fnetwork\u002F",[44],"Docker network",[13,5575,5576,5577,5582,5583,5588],{},"The proxy must supply one thing, the hostname. Projects with multiple domains, for example a ",[17,5578,5581],{"href":5579,"rel":5580},"https:\u002F\u002Fcraftcms.com\u002Fdocs\u002F5.x\u002Fsystem\u002Fsites.html",[44],"Craft multisite",", select the site from the requested domain. But the preview runs under a Knecht URL, not under the real domain of the project. Therefore the proxy sends the real domain in the ",[17,5584,5587],{"href":5585,"rel":5586},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FHTTP\u002FReference\u002FHeaders\u002FHost",[44],"Host header",". For the project, each request looks as if it came over the normal URL.",[13,5590,5591],{},"Parallel runs of the same project must not conflict with each other. Therefore Knecht registers each run as a separate DDEV project with a unique name. The change to the DDEV config, a minus point in the last post, remains, but the project does not see it. The code in the repo stays untouched. Knecht only rewrites the generated runtime config.",[57,5593,5595],{"id":5594},"the-tradeoff","The tradeoff",[13,5597,5598,5599,5604,5605,5610],{},"For security, this is a step back compared with Sysbox. Sysbox mapped root in the container to an unprivileged host user with ",[17,5600,5603],{"href":5601,"rel":5602},"https:\u002F\u002Fman7.org\u002Flinux\u002Fman-pages\u002Fman7\u002Fuser_namespaces.7.html",[44],"user namespaces",". Now the containers run again with ",[17,5606,5609],{"href":5607,"rel":5608},"https:\u002F\u002Fgithub.com\u002Fopencontainers\u002Frunc",[44],"runc",", the normal Docker runtime, and the border to the host is thinner. The points above compensate for this, because the run has no socket, its own network, and fixed limits.",[13,5612,4774,5613,5618],{},[17,5614,5617],{"href":5615,"rel":5616},"https:\u002F\u002Fowasp.org\u002Fwww-community\u002FThreat_Modeling",[44],"threat model"," decides if this step back is acceptable, as in the last post. Knecht runs at an agency, for its own projects, on its own server. The attacker that the sandbox would block in addition needs a kernel exploit. This attacker then lands on a server that only contains the projects of the agency. To pay 3 GB instead of 180 MB per preview for this risk is the wrong trade.",[57,5620,5622],{"id":5621},"the-result","The result",[13,5624,5625],{},"Both variants ran on the same dev machine, measured in July 2026:",[3154,5627,5628,5640],{},[3157,5629,5630],{},[3160,5631,5632,5634,5637],{},[3163,5633],{},[3163,5635,5636],{},"Sandbox per run (Sysbox)",[3163,5638,5639],{},"DDEV on the host (today)",[3172,5641,5642,5653,5664,5675,5686],{},[3160,5643,5644,5647,5650],{},[3177,5645,5646],{},"RAM per active preview",[3177,5648,5649],{},"~3 GB",[3177,5651,5652],{},"~180 MB",[3160,5654,5655,5658,5661],{},[3177,5656,5657],{},"DDEV images",[3177,5659,5660],{},"per sandbox, 1-2 GB extra",[3177,5662,5663],{},"once per host",[3160,5665,5666,5669,5672],{},[3177,5667,5668],{},"Wake a stopped preview",[3177,5670,5671],{},"minutes, a complete sandbox boot",[3177,5673,5674],{},"seconds",[3160,5676,5677,5680,5683],{},[3177,5678,5679],{},"Docker access in the run",[3177,5681,5682],{},"own daemon in the sandbox",[3177,5684,5685],{},"none, Knecht controls from outside",[3160,5687,5688,5691,5694],{},[3177,5689,5690],{},"Isolation border",[3177,5692,5693],{},"own daemon plus user namespaces",[3177,5695,5696],{},"own network, limits, no socket",[13,5698,5699],{},"Two smaller optimizations come on top.",[1688,5701,5702,5711],{},[1691,5703,5704,5705,5710],{},"The database of a preview runs with a leaner configuration. The MySQL config that DDEV supplies permits about 1 GB of ",[17,5706,5709],{"href":5707,"rel":5708},"https:\u002F\u002Fmariadb.com\u002Fkb\u002Fen\u002Finnodb-buffer-pool\u002F",[44],"buffer"," for each database.",[1691,5712,5713],{},"A stopped preview uses 0 RAM. Only the database volume and the code remain.",[13,5715,5716],{},"Before, one server could hold a few previews. Now it can hold almost all projects of an agency at the same time.",[57,5718,5720],{"id":5719},"what-we-learned","What we learned",[13,5722,5723],{},"The last post started with the sentence that we test the riskiest assumption first. That was correct, and it worked, the sandbox ran. But the correct last question is not if the system runs. The correct question is if it runs at a cost that matches the goal. You can only answer this question when the system is under real, parallel load. We now make this measurement earlier.",{"title":88,"searchDepth":127,"depth":127,"links":5725},[5726,5727,5728,5732,5733,5734],{"id":5481,"depth":127,"text":5482},{"id":5510,"depth":127,"text":5511},{"id":5540,"depth":127,"text":5541,"children":5729},[5730,5731],{"id":5544,"depth":147,"text":5545},{"id":5564,"depth":147,"text":5565},{"id":5594,"depth":127,"text":5595},{"id":5621,"depth":127,"text":5622},{"id":5719,"depth":127,"text":5720},"2026-07-26","The sandbox per run worked, and it used 3 GB of RAM per preview. This post explains why we removed it again and how we solve the old problems today.",{},{"title":5451,"description":5736},"updates\u002Fsandbox-rollback","Zv43w1FrBSfERnYrveT71bEwkaI9pdOyrAzVRmK32SE",{"id":5742,"title":5743,"body":5744,"date":5944,"description":5945,"extension":1734,"meta":5946,"navigation":522,"path":4273,"seo":5947,"stem":5948,"tag":4010,"__hash__":5949},"updates_en\u002Fupdates\u002Fworkflow-engine.md","Automate DDEV Projects with Workflows",{"type":7,"value":5745,"toc":5933},[5746,5749,5755,5759,5778,5781,5787,5794,5798,5801,5826,5833,5837,5844,5850,5857,5861,5864,5870,5874,5877,5897,5900,5904,5907,5913,5916,5920,5923,5926,5930],[13,5747,5748],{},"A workflow is a list of steps that Knecht executes automatically for a project. For example, start the project, update the Composer packages, run the tests, and open a pull request. Almost all of the last weeks went into this engine. This post explains what the engine can do, what separates the AI action from a chat, and what is still absent.",[13,5750,5751],{},[3517,5752],{"alt":5753,"src":5754},"The workflow editor with trigger, steps, and the action library","\u002Fassets\u002Fworkflow-detail.png",[57,5756,5758],{"id":5757},"how-a-workflow-runs","How a workflow runs",[13,5760,5761,5762,5766,5767,5769,5770,5773,5774,5777],{},"When a workflow starts, Knecht creates a ",[5763,5764,5765],"strong",{},"run",", a single execution on a project. Each run gets its own, isolated environment, the ",[5763,5768,4780],{},". The complete project runs in it. The code comes fresh from GitHub, Knecht imports the database, and ",[17,5771,3133],{"href":5494,"rel":5772},[44]," starts. The live system and other runs do not notice it. The ",[17,5775,5776],{"href":5461},"architecture post"," explains why each run gets its own sandbox.",[13,5779,5780],{},"The steps run one after the other, and each step leaves results. Later steps insert these results with placeholders.",[83,5782,5785],{"className":5783,"code":5784,"language":3853},[3851],"{{ steps.run_tests.stdout }}      the output of the step \"Run tests\"\n{{ inputs.title }}                the title of the ticket that started the workflow\n",[90,5786,5784],{"__ignoreMap":88},[13,5788,5789,5790,5793],{},"You do not need to memorize this. When you type ",[90,5791,5792],{},"{{ ",", a list shows everything that is available at this position. And after each step, Knecht saves what happened. Therefore you see live where a run is, and you can continue a failed run exactly from the step where it stopped.",[57,5795,5797],{"id":5796},"the-actions","The actions",[13,5799,5800],{},"Each step executes one action. There are roughly three types.",[5802,5803,5804,5815,5821],"field-group",{},[5805,5806,5808],"field",{"name":5807},"Deterministic actions",[13,5809,5810,5811,5814],{},"They do exactly one task, always in the same way. They boot the project, execute a shell command like ",[90,5812,5813],{},"composer update",", run your own JavaScript code, call an external URL, check the complete sitemap for broken links, or bring the changes back to GitHub as a pull request.",[5805,5816,5818],{"name":5817},"Control flow actions",[13,5819,5820],{},"If\u002Felse executes steps only under a condition, for example \"when the tests failed\". Loop repeats steps for each entry of a list.",[5805,5822,5823],{"name":4274},[13,5824,5825],{},"It lets an agent work in the active project. More about this below.",[13,5827,5828,5829,5832],{},"Not every error is a real error, so each step has its own error policy. Unstable commands get retries with backoff, and with ",[90,5830,5831],{},"continueOnError"," the run continues after an error. The library still grows. The screenshot above shows a part of the current actions.",[62,5834,5836],{"id":5835},"an-agent-not-a-chat","An agent, not a chat",[13,5838,5839,5840,5843],{},"The AI action starts ",[17,5841,4279],{"href":4277,"rel":5842},[44]," with your own API key directly in the sandbox. The agent works in the real, active project. It reads code, changes files, and executes commands. A chat tells you what a fix could look like. The agent applies the fix and tests if it works.",[13,5845,5846],{},[3517,5847],{"alt":5848,"src":5849},"The AI action with prompt, fixed output format, and the available variables","\u002Fassets\u002Fworkflow-ai-action.png",[13,5851,5852,5853,5856],{},"Instead of free text, the agent can deliver fixed output fields like ",[90,5854,5855],{},"prTitle",", as validated JSON for later steps. And the agent does not disappear after the run. A chat on the run page continues the same session. \"Make the button blue\" does not need a new run.",[62,5858,5860],{"id":5859},"locked-in-the-sandbox","Locked in the sandbox",[13,5862,5863],{},"Everything that a workflow executes (shell commands, your own code, the agent) runs only in the sandbox, never on the server itself.",[13,5865,5866,5867,5869],{},"Project secrets also stay where they belong. Knecht stores API keys and tokens ",[17,5868,4494],{"href":4493}," in the database. The preview URLs are also not public. To open one, you need a Knecht login.",[57,5871,5873],{"id":5872},"the-triggers","The triggers",[13,5875,5876],{},"A trigger defines when a workflow starts on its own. This is the point where a tool becomes automation, because nobody must remember to start the process. The workflow runs when its condition occurs, also at night and on the weekend. There are currently three types.",[5802,5878,5879,5885,5891],{},[5805,5880,5882],{"name":5881},"Manual",[13,5883,5884],{},"With a click in the dashboard, or as a test run directly in the editor.",[5805,5886,5888],{"name":5887},"Integrations",[13,5889,5890],{},"Knecht reacts to events from GitHub and Jira. For example, when a Jira ticket gets the label \"knecht\", the workflow starts, and the link to the finished PR appears as a comment on the ticket.",[5805,5892,5894],{"name":5893},"Cron",[13,5895,5896],{},"On a schedule, for example a check of the security updates each Monday at 6:00.",[13,5898,5899],{},"A trigger can include multiple projects, and it starts a separate run for each project.",[57,5901,5903],{"id":5902},"after-the-start","After the start",[13,5905,5906],{},"Before you activate a trigger, you can test the workflow directly in the editor, against a real project in its own sandbox. The same rules apply to test runs and real runs.",[13,5908,5909],{},[3517,5910],{"alt":5911,"src":5912},"A test run directly in the editor, with live log and active step","\u002Fassets\u002Fworkflow-test-run.png",[13,5914,5915],{},"Inside a run, everything runs one step after the other, on purpose. There are no parallel branches. A run stays a linear, traceable chain. You can configure how many runs execute at the same time. All runs above that limit wait in the queue.",[57,5917,5919],{"id":5918},"workflows-as-code","Workflows as code",[13,5921,5922],{},"You can export each workflow as YAML or JSON and import it again. Therefore a workflow can move into the Git repo, get reviews like all other code, and move between projects or instances. If you prefer a text editor, you write the file directly and import the finished file.",[13,5924,5925],{},"The format also opens a door for later. A workflow that works well at one agency works the same at the next agency, because it only contains steps and placeholders. A possible future step is a workflow store. There you install finished workflows, for example security updates or link checks, instead of a manual setup.",[57,5927,5929],{"id":5928},"what-is-still-absent","What is still absent",[13,5931,5932],{},"A notification system does not exist yet. When a run fails, you only see it in the dashboard. If you want an immediate signal, call a Slack webhook with the HTTP step at the end of the workflow. A hard time limit per run is also absent. A stuck step runs until you stop it.",{"title":88,"searchDepth":127,"depth":127,"links":5934},[5935,5936,5940,5941,5942,5943],{"id":5757,"depth":127,"text":5758},{"id":5796,"depth":127,"text":5797,"children":5937},[5938,5939],{"id":5835,"depth":147,"text":5836},{"id":5859,"depth":147,"text":5860},{"id":5872,"depth":127,"text":5873},{"id":5902,"depth":127,"text":5903},{"id":5918,"depth":127,"text":5919},{"id":5928,"depth":127,"text":5929},"2026-07-13","Automation for DDEV projects. Knecht runs Composer updates and tests in a sandbox and opens pull requests. With an AI agent that fixes bugs in the project.",{},{"title":5743,"description":5945},"updates\u002Fworkflow-engine","iDTMy8jpKJy52e8NJXs3vmRPJEVnm1qiFltueAFfpPc",{"id":5951,"title":5952,"body":5953,"date":6195,"description":6196,"extension":1734,"meta":6197,"navigation":522,"path":4493,"seo":6198,"stem":6199,"tag":6200,"__hash__":6201},"updates_en\u002Fupdates\u002Fgh-auth-evolution.md","GitHub Auth Evolution",{"type":7,"value":5954,"toc":6187},[5955,5958,5961,5965,5986,5989,5993,5999,6016,6020,6033,6062,6068,6079,6083,6086,6098,6107,6110,6113,6117,6128,6136,6151,6157,6164,6167,6180,6184],[13,5956,5957],{},"The GitHub login worked from day one. Still, we rebuilt it three times.",[13,5959,5960],{},"The reason is that \"connect with GitHub\" hides three separate questions, and people often mix them. Who sits in front of the dashboard? With what does the server clone repos and open PRs? And who is permitted to use this instance at all? At the start, we answered all three with a single token. This post shows the path from there to the current state, together with the GitHub quirks that we found on the way.",[57,5962,5964],{"id":5963},"what-an-oauth-app-is","What an OAuth App is",[13,5966,5967,5968,5973,5974,5977,5978,5981,5982,5985],{},"An OAuth App is the classic \"Sign in with GitHub\" button. The app sends the user to GitHub. The user confirms the access there and comes back with an access token. The ",[17,5969,5972],{"href":5970,"rel":5971},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Fapps\u002Foauth-apps\u002Fbuilding-oauth-apps\u002Fscopes-for-oauth-apps",[44],"scopes"," that the app requests define what this token can do. ",[90,5975,5976],{},"read:user"," means that the app can only read the profile. ",[90,5979,5980],{},"repo"," means full read and write access to ",[5763,5983,5984],{},"all"," repos that the user can reach.",[13,5987,5988],{},"And exactly there our problem started.",[57,5990,5992],{"id":5991},"stage-1-one-token-for-everything","Stage 1: one token for everything",[13,5994,5995,5996,5998],{},"The first version of Knecht was an OAuth App with the ",[90,5997,5980],{}," scope. The token that confirmed the login was also the credential for all Git operations. There was no second secret and no setup. This was practical, but wrong in two ways:",[1688,6000,6001,6010],{},[1691,6002,6003,6006,6007,6009],{},[5763,6004,6005],{},"The scope is too wide."," A ",[90,6008,5980],{}," token can do everything that the user can do, in each of the user's repos, not only in the projects that Knecht maintains. OAuth scopes cannot be more precise than this.",[1691,6011,6012,6015],{},[5763,6013,6014],{},"The credential belongs to one person."," If the user rotates the token or leaves the agency, the server loses access to all repos. Server infrastructure that depends on the account of one employee is a time bomb.",[57,6017,6019],{"id":6018},"what-a-github-app-does-differently","What a GitHub App does differently",[13,6021,6022,6023,6026,6027,6032],{},"For stage 2, we moved the repo access to a ",[5763,6024,6025],{},"GitHub App",". The ",[17,6028,6031],{"href":6029,"rel":6030},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Fapps\u002Foauth-apps\u002Fbuilding-oauth-apps\u002Fdifferences-between-github-apps-and-oauth-apps",[44],"difference to an OAuth App"," is fundamental:",[1688,6034,6035,6042,6049],{},[1691,6036,6037,6038,6041],{},"A GitHub App is a ",[5763,6039,6040],{},"separate actor"," with its own identity, not a user who lends personal permissions.",[1691,6043,6044,6045,6048],{},"You ",[5763,6046,6047],{},"install"," it on selected repos. The access is limited to exactly these repos.",[1691,6050,6051,6052,6055,6056,6061],{},"It authenticates with its ",[5763,6053,6054],{},"private key"," and creates ",[17,6057,6060],{"href":6058,"rel":6059},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Fapps\u002Fcreating-github-apps\u002Fauthenticating-with-a-github-app\u002Fgenerating-an-installation-access-token-for-a-github-app",[44],"installation tokens"," from it. Each token is valid for about one hour and scoped to the installed repos. When one token expires, the app simply creates the next one.",[13,6063,6064,6065,6067],{},"The login stayed OAuth, but with a smaller scope, ",[90,6066,5976],{},". Knecht uses the token one time to load the profile and then discards it. It never stores the token. With this, identity and repo access were cleanly separated.",[13,6069,6070,6071,6074,6075,6078],{},"But the setup was painful. The operator had to create an OAuth App ",[5763,6072,6073],{},"and"," a GitHub App by hand, copy four secrets into the ",[90,6076,6077],{},".env",", encode the private key as base64, and enter callback URLs. All this before anything worked.",[57,6080,6082],{"id":6081},"stage-3-the-manifest-flow","Stage 3: the Manifest Flow",[13,6084,6085],{},"Then we noticed that a GitHub App already contains its own OAuth client. Thus a single app can do both, login and repo access. The separate OAuth App was unnecessary.",[13,6087,6088,6089,6094,6095,6097],{},"And for exactly this case, GitHub has the ",[17,6090,6093],{"href":6091,"rel":6092},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Fapps\u002Fsharing-github-apps\u002Fregistering-a-github-app-from-a-manifest",[44],"App Manifest Flow",". A program describes the desired app as JSON (name, callback URLs, permissions) and sends it to GitHub. GitHub creates the app and returns ",[5763,6096,5984],{}," credentials.",[13,6099,6100,6101,6106],{},"In Knecht, this works as follows. At the first start, the dashboard shows a button. One click, GitHub creates the app, and Knecht stores the credentials encrypted in its own database. The cipher is AES-256-GCM, and ",[17,6102,6105],{"href":6103,"rel":6104},"https:\u002F\u002Fdatatracker.ietf.org\u002Fdoc\u002Fhtml\u002Frfc5869",[44],"HKDF"," derives the key from the session password, which must be set in any case. Thus the encryption costs no new env variable.",[13,6108,6109],{},"Exactly two things remain for the operator to supply: the session password and the base URL.",[13,6111,6112],{},"There is one trade-off. If you rotate the session password, the stored credentials become unreadable. The solution then is not to decrypt them but to run through the setup one more time.",[57,6114,6116],{"id":6115},"stage-4-who-gets-access","Stage 4: who gets access",[13,6118,6119,6120,6123,6124,6127],{},"One hole stayed open until the end. The login checked ",[5763,6121,6122],{},"identity"," but not ",[5763,6125,6126],{},"permission",". Each GitHub account that completed the OAuth flow got a session.",[13,6129,6130,6131,202],{},"Whether an attacker can use this depends on a GitHub quirk that you must understand one time. An app is either ",[17,6132,6135],{"href":6133,"rel":6134},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Fapps\u002Fcreating-github-apps\u002Fsetting-up-a-github-app\u002Fmaking-a-github-app-public-or-private",[44],"private or public",[1688,6137,6138,6145],{},[1691,6139,6140,6141,6144],{},"A ",[5763,6142,6143],{},"private"," GitHub App shows the authorize page only to its owner (or to members of the owner org). All others see a 404. This looks like built-in access control, but it makes it impossible to invite external team members. They do not even reach the consent page.",[1691,6146,6140,6147,6150],{},[5763,6148,6149],{},"public"," app lets each GitHub user through the identity flow. Only this makes it possible to onboard any team member. But then GitHub gates nothing at all.",[3968,6152,6154],{"color":4236,"icon":6153},"i-lucide-lightbulb",[13,6155,6156],{},"The visibility of a GitHub App is not access control. Private locks out the wrong people, and public lets everybody in. If you want to decide who can log in, you must do it yourself.",[13,6158,6159,6160,6163],{},"Thus the app of Knecht is public, and Knecht itself holds the permission with an ",[5763,6161,6162],{},"allowlist",". This is a simple members table with GitHub logins. During the setup, Knecht automatically adds the creator as the owner. You invite all other members through the settings. After the GitHub login, Knecht rejects each person who is not on the list.",[13,6165,6166],{},"\"Public\" only means that strangers could install the app on their own repos. This is harmless, because Knecht only uses installations that it knows.",[13,6168,6169,6170,6175,6176,6179],{},"One more detail, because people often miss it. A session is typically a cookie that stays valid until it expires. If you check only at login, removed members stay in the system for days. ",[17,6171,6174],{"href":6172,"rel":6173},"https:\u002F\u002Fcheatsheetseries.owasp.org\u002Fcheatsheets\u002FSession_Management_Cheat_Sheet.html",[44],"OWASP calls this a lack of server-side invalidation",". Thus Knecht checks the membership again at ",[5763,6177,6178],{},"each"," request. When someone is removed, that person is out at the next click.",[57,6181,6183],{"id":6182},"what-is-still-open","What is still open",[13,6185,6186],{},"Roles. At the moment, each member has full access, and this includes invitations. For the small teams that Knecht is built for now, this is enough. Finer permissions will come when teams need real roles.",{"title":88,"searchDepth":127,"depth":127,"links":6188},[6189,6190,6191,6192,6193,6194],{"id":5963,"depth":127,"text":5964},{"id":5991,"depth":127,"text":5992},{"id":6018,"depth":127,"text":6019},{"id":6081,"depth":127,"text":6082},{"id":6115,"depth":127,"text":6116},{"id":6182,"depth":127,"text":6183},"2026-07-06","A deep dive into the GitHub connection of Knecht. Why an OAuth token is not enough, what a GitHub App does differently, and why we had to build the access control ourselves in the end.",{},{"title":5952,"description":6196},"updates\u002Fgh-auth-evolution","Security","LJpgokObP32ps2nzJAQKU0zEDrjM7mRYl1k-uP3MDOw",{"id":6203,"title":6204,"body":6205,"date":6479,"description":6480,"extension":1734,"meta":6481,"navigation":522,"path":5461,"seo":6482,"stem":6483,"tag":1739,"__hash__":6484},"updates_en\u002Fupdates\u002Fdashboard-architecture.md","From a Shared Docker Daemon to One Sandbox per Run",{"type":7,"value":6206,"toc":6458},[6207,6226,6229,6233,6244,6259,6263,6307,6310,6314,6317,6321,6331,6335,6342,6346,6349,6352,6356,6367,6371,6395,6399,6418,6422,6435,6439,6448,6452,6455],[10,6208,6209,6219],{},[13,6210,6211,6212,31,6214,6218],{},"This post is a look under the hood. The container architecture is the base that makes parallel previews with DDEV projects possible. It is not the product itself. The ",[17,6213,20],{"href":19},[17,6215,6217],{"href":6216},"\u002Fupdates\u002Fwas-macht-knecht","What Knecht does"," explain what Knecht does and who it is for.",[13,6220,6221,6222,6225],{},"Update, July 2026: We built the Sysbox sandbox, and it worked. Still, we removed it again. The follow-up post ",[17,6223,6224],{"href":4663},"Back to the shared daemon"," explains why. The path to the decision below is still worth a read, because the follow-up post builds on it.",[13,6227,6228],{},"Knecht itself runs as a Docker container, but it must boot DDEV projects, which also consist of containers. The most risky assumption in the full architecture was that this works at all. Thus we tested this assumption first, before we wrote the real code.",[57,6230,6232],{"id":6231},"the-approach","The approach",[13,6234,6235,6236,6239,6240,6243],{},"Some background first. Docker consists of two parts. The ",[17,6237,5477],{"href":5475,"rel":6238},[44]," is the service that really creates and manages containers. The Docker CLI is only a client that talks to the daemon through a Unix socket (",[90,6241,6242],{},"\u002Fvar\u002Frun\u002Fdocker.sock","). If you have the socket, you control the daemon.",[13,6245,6246,6247,6252,6253,6258],{},"Knecht uses exactly this. The container does not start its own daemon. It mounts the socket of the host and thus controls the host daemon. This pattern is called ",[17,6248,6251],{"href":6249,"rel":6250},"https:\u002F\u002Fwww.avonture.be\u002Fblog\u002Fdocker-out-of-docker-dood\u002F",[44],"Docker-out-of-Docker"," (DooD). The DDEV containers then run as siblings next to the Knecht container on the host, not nested inside it. ",[17,6254,6257],{"href":6255,"rel":6256},"https:\u002F\u002Fcoolify.io",[44],"Coolify"," and many CI runners work in the same way.",[57,6260,6262],{"id":6261},"the-test","The test",[3552,6264,6266,6270,6276,6280,6286,6290,6293,6297],{"level":6265},"3",[62,6267,6269],{"id":6268},"the-host-daemon-is-reachable","The host daemon is reachable",[13,6271,6272,6275],{},[90,6273,6274],{},"docker ps"," in the container shows the host containers. The socket mount works.",[62,6277,6279],{"id":6278},"a-ddev-project-boots","A DDEV project boots",[13,6281,6282,6283,6285],{},"We put a minimal PHP project at a fixed path and ran ",[90,6284,3651],{},". DDEV started the web server and the database on the host.",[62,6287,6289],{"id":6288},"containers-are-host-siblings","Containers are host siblings",[13,6291,6292],{},"On the host, the project containers appear as normal neighbors of the Knecht container.",[62,6294,6296],{"id":6295},"the-app-is-reachable","The app is reachable",[13,6298,6299,6300,6303,6304,6306],{},"The booted project responds. One detail is important here. ",[90,6301,6302],{},"*.ddev.site"," always resolves through DNS to ",[90,6305,3666],{},", which is the container itself. Thus the preview proxy must contact the web container directly through the Docker network.",[13,6308,6309],{},"This proves the base. A process in the Knecht container can boot real DDEV stacks, with the source code correctly mounted.",[57,6311,6313],{"id":6312},"two-problems-remained","Two problems remained",[13,6315,6316],{},"But the test also showed the limits of the approach.",[62,6318,6320],{"id":6319},"no-isolation-between-runs","No isolation between runs",[13,6322,6323,6324,6327,6328,6330],{},"Everything runs on one shared daemon. But Knecht must run many previews in parallel, without conflicts between runs. We even had to remove the ",[17,6325,5524],{"href":5522,"rel":6326},[44],". The router is the reverse proxy that DDEV puts in front of all projects and that binds ports 80 and 443 on the host. Fixed host ports exist only one time, and many parallel projects then collide. Without the router, Knecht must change the ",[90,6329,3121],{}," and, for example, overwrite the URLs.",[62,6332,6334],{"id":6333},"foreign-code-at-the-host-socket","Foreign code at the host socket",[13,6336,6337,6338,6341],{},"The agent executes code, and we do not fully control what it does. An attacker can hijack an agent through ",[17,6339,5560],{"href":5558,"rel":6340},[44],", for example with manipulated content in the repo or in a data source. Then the agent no longer does what we want. An attacker who reaches the host socket at this point controls the daemon. And who controls the daemon can start any container with any mount. In effect, this is root on the server.",[57,6343,6345],{"id":6344},"the-solution-one-sandbox-per-run","The solution: one sandbox per run",[13,6347,6348],{},"Both problems have the same answer. Each run gets its own Docker daemon in its own sandbox. With this, the reason to remove the router also disappears. Port 80 in sandbox A cannot collide with sandbox B, because each sandbox has its own network namespace. Thus projects run again with all their URLs and settings.",[13,6350,6351],{},"The open question is what the sandbox is. We examined the usual candidates.",[62,6353,6355],{"id":6354},"privileged-docker-in-docker","Privileged Docker-in-Docker",[13,6357,6358,6359,6366],{},"This is a second daemon in a container with ",[17,6360,6363],{"href":6361,"rel":6362},"https:\u002F\u002Fdocs.docker.com\u002Fengine\u002Fcontainers\u002Frun\u002F#runtime-privilege-and-linux-capabilities",[44],[90,6364,6365],{},"--privileged",". The flag turns off almost all protection mechanisms and gives the container access to the host devices. A malicious dependency then reaches the host without an exploit. Not an option.",[62,6368,6370],{"id":6369},"kata-containers","Kata Containers",[13,6372,6373,6378,6379,6384,6385,6388,6389,6394],{},[17,6374,6377],{"href":6375,"rel":6376},"https:\u002F\u002Fkatacontainers.io",[44],"Kata"," starts each container in a lightweight VM with its own kernel. This is the strongest boundary and the approach behind ",[17,6380,6383],{"href":6381,"rel":6382},"https:\u002F\u002Ffirecracker-microvm.github.io",[44],"Firecracker",", on which, for example, AWS Lambda runs. But Kata needs ",[90,6386,6387],{},"\u002Fdev\u002Fkvm",", and normal cloud VPS providers offer ",[17,6390,6393],{"href":6391,"rel":6392},"https:\u002F\u002Flowendtalk.com\u002Fdiscussion\u002F154261\u002Fsearching-vps-for-nested-virtualization",[44],"no nested virtualization",". This would force bare metal and conflicts with the goal to install Knecht on a normal VPS. Not an option.",[62,6396,6398],{"id":6397},"gvisor","gVisor",[13,6400,6401,6405,6406,6411,6412,6417],{},[17,6402,6398],{"href":6403,"rel":6404},"https:\u002F\u002Fgvisor.dev",[44]," intercepts syscalls in a userspace kernel and needs no KVM. It isolates application processes well, but it has problems as a Docker host itself. ",[17,6407,6410],{"href":6408,"rel":6409},"https:\u002F\u002Fgithub.com\u002Fgoogle\u002Fgvisor\u002Fissues\u002F9917",[44],"It supports iptables only in part",", and ",[17,6413,6416],{"href":6414,"rel":6415},"https:\u002F\u002Fgithub.com\u002Fgoogle\u002Fgvisor\u002Fissues\u002F12475",[44],"overlayfs breaks with Docker v29",". But a Docker host is exactly what DDEV needs. Not an option.",[62,6419,6421],{"id":6420},"our-choice-sysbox","Our choice: Sysbox",[13,6423,6424,6427,6428,6430,6431,6434],{},[17,6425,5471],{"href":5469,"rel":6426},[44]," is a container runtime that permits Docker-in-Docker without ",[90,6429,6365],{},". For this, it uses ",[17,6432,5603],{"href":5601,"rel":6433},[44],". Root in the container is mapped to an unprivileged user on the host. Sysbox was built exactly for this case, needs no KVM, and thus runs on every normal Linux server.",[57,6436,6438],{"id":6437},"the-threat-model-decides","The threat model decides",[13,6440,6441,6442,6447],{},"In the end, the decision does not come from the runtime but from the threat model. The threat model asks who attacks and what the attacker can reach. Knecht is ",[17,6443,6446],{"href":6444,"rel":6445},"https:\u002F\u002Fwww.geeksforgeeks.org\u002Fsystem-design\u002Fsingle-tenant-vs-multi-tenant-architecture\u002F",[44],"single-tenant",", one agency, its projects, its server. The VM boundary of Kata mostly protects against a customer who breaks out of one environment into the environment of another customer. This case does not exist here.",[57,6449,6451],{"id":6450},"what-comes-next","What comes next",[13,6453,6454],{},"Next, we build the Sysbox sandbox per run (own daemon, DDEV with router) and a central ingress with an auth gate that routes preview URLs into the correct sandbox. The next milestone is DDEV with the router in a Sysbox sandbox on a real Linux host, reachable from the ingress.",[13,6456,6457],{},"Perhaps there will also be a first showcase of the prototype.",{"title":88,"searchDepth":127,"depth":127,"links":6459},[6460,6461,6467,6471,6477,6478],{"id":6231,"depth":127,"text":6232},{"id":6261,"depth":127,"text":6262,"children":6462},[6463,6464,6465,6466],{"id":6268,"depth":147,"text":6269},{"id":6278,"depth":147,"text":6279},{"id":6288,"depth":147,"text":6289},{"id":6295,"depth":147,"text":6296},{"id":6312,"depth":127,"text":6313,"children":6468},[6469,6470],{"id":6319,"depth":147,"text":6320},{"id":6333,"depth":147,"text":6334},{"id":6344,"depth":127,"text":6345,"children":6472},[6473,6474,6475,6476],{"id":6354,"depth":147,"text":6355},{"id":6369,"depth":147,"text":6370},{"id":6397,"depth":147,"text":6398},{"id":6420,"depth":147,"text":6421},{"id":6437,"depth":127,"text":6438},{"id":6450,"depth":127,"text":6451},"2026-07-01","Knecht can boot real DDEV projects from inside a container. What the test showed, and why each run will get its own sandbox.",{},{"title":6204,"description":6480},"updates\u002Fdashboard-architecture","eZZbahnoWIzinqa_CJIm1LU-0WRmMyalOWUH0ev3QdA",{"id":6486,"title":6487,"body":6488,"date":6768,"description":6769,"extension":1734,"meta":6770,"navigation":522,"path":6771,"seo":6772,"stem":6773,"tag":6774,"__hash__":6775},"updates_en\u002Fupdates\u002Fmobile-performance.md","From 5 Seconds to Under 0.5",{"type":7,"value":6489,"toc":6752},[6490,6497,6500,6503,6510,6516,6520,6541,6544,6573,6576,6580,6589,6592,6602,6605,6634,6646,6650,6653,6665,6669,6672,6678,6681,6684,6688,6697,6721,6725,6728,6740,6745,6749],[13,6491,6492,6493,6496],{},"On desktop the landing page loaded immediately. On mobile it was very slow, more than five seconds until it appeared. The cause was a single CSS effect, ",[90,6494,6495],{},"blur()",". This post shows the full path from the diagnosis to what really happens under the hood.",[57,6498,6499],{"id":59},"The problem",[13,6501,6502],{},"On mobile the page was simply slow. The load took a long time, and on desktop it was fast. We checked the usual suspects, large images, too much JavaScript, and a slow server. None of them was the cause.",[13,6504,6505,6506,6509],{},"In the end the cause was the background. It contains some large, soft light spots (glows), made with ",[90,6507,6508],{},"filter: blur()",". One element was more than 1000px wide, with a blur radius of 100px.",[3968,6511,6513],{"color":4236,"icon":6512},"i-lucide-zap-off",[13,6514,6515],{},"More than five seconds to the first frame kills a landing page. That moment decides if a visitor stays.",[57,6517,6519],{"id":6518},"why-blur-is-expensive","Why blur is expensive",[13,6521,6522,6524,6525,6530,6531,6533,6534,6537,6538,36],{},[90,6523,6495],{}," is a ",[17,6526,6529],{"href":6527,"rel":6528},"https:\u002F\u002Fdeveloper.chrome.com\u002Fblog\u002Fanimated-blur",[44],"convolution",". The browser computes each output pixel from the weighted input pixels around it. The cost scales with the area ",[5763,6532,6073],{}," the radius. A ",[90,6535,6536],{},"blur(100px)"," is therefore much more expensive than a ",[90,6539,6540],{},"blur(5px)",[13,6542,6543],{},"This happens for each frame, in about these steps:",[3552,6545,6546,6550,6553,6557,6560,6564],{"level":6265},[62,6547,6549],{"id":6548},"render-to-texture","Render-to-texture",[13,6551,6552],{},"First the browser renders the element into its own off-screen texture. This alone promotes the element to its own compositing layer and costs GPU memory.",[62,6554,6556],{"id":6555},"separable-gaussian","Separable Gaussian",[13,6558,6559],{},"The engine splits a 2D Gaussian into two 1D passes, first horizontal, then vertical. This lowers the cost from O(n²) to O(n) per pixel. This is already the optimized variant.",[62,6561,6563],{"id":6562},"downsampling","Downsampling",[13,6565,6566,6567,6572],{},"For large radii, the engine does not compute at full resolution. Skia (Chrome) ",[17,6568,6571],{"href":6569,"rel":6570},"https:\u002F\u002Fapi.skia.org\u002FclassSkImageFilters.html",[44],"scales down at a sigma above 4",", blurs at the smaller buffer size, and scales up again.",[13,6574,6575],{},"Despite all these optimizations, a very large area remains, and it must be ready before the first paint. On desktop you do not notice this. A mobile GPU does this work much more slowly, and exactly this delayed the first frame by seconds.",[57,6577,6579],{"id":6578},"why-mobile-gpus-suffer-most","Why mobile GPUs suffer most",[13,6581,6582,6583,6588],{},"Almost all mobile GPUs work ",[17,6584,6587],{"href":6585,"rel":6586},"https:\u002F\u002Fdeveloper.samsung.com\u002Fgalaxy-gamedev\u002Fresources\u002Farticles\u002Fgpu-framebuffer.html",[44],"tile-based",". The GPU splits the framebuffer into small tiles and renders them in fast on-chip memory. This saves much memory bandwidth, but it only works while the GPU can compute each tile on its own.",[13,6590,6591],{},"A blur breaks exactly this assumption. The convolution must read pixels beyond the tile border. The GPU cannot keep the effect local to a tile. It must write to the slow system memory and read from it again. On hardware with low fill rate and bandwidth, this is the most expensive case.",[57,6593,6595,6598,6599],{"id":6594},"filter-is-not-backdrop-filter",[90,6596,6597],{},"filter"," is not ",[90,6600,6601],{},"backdrop-filter",[13,6603,6604],{},"The header contained a second blur, and that one is a different problem. People often confuse the two:",[1688,6606,6607,6618],{},[1691,6608,6609,6613,6614,6617],{},[5763,6610,6611],{},[90,6612,6508],{}," blurs the ",[5763,6615,6616],{},"own pixels"," of the element. The content behind it does not matter.",[1691,6619,6620,6629,6630,6633],{},[5763,6621,6622],{},[17,6623,6626],{"href":6624,"rel":6625},"https:\u002F\u002Fwww.w3tweaks.com\u002Fcss\u002Fcss-filter-backdrop-filter\u002F",[44],[90,6627,6628],{},"backdrop-filter: blur()"," blurs ",[5763,6631,6632],{},"everything behind the element",", and it reads those pixels again in each frame.",[13,6635,6636,6637,6642,6643,6645],{},"The second one is brutal when you scroll. The backdrop changes all the time, so the browser computes the blur again and again. ",[17,6638,6641],{"href":6639,"rel":6640},"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fvitepress\u002Fissues\u002F1049",[44],"This causes visible stutter",", for example in older Firefox builds on Linux. A fixed, semi-transparent header with ",[90,6644,6601],{}," is one of the most frequent causes of jank.",[57,6647,6649],{"id":6648},"blur-and-animation","Blur and animation",[13,6651,6652],{},"In short, do not animate the radius. Each frame triggers the full convolution on the GPU and breaks the 16ms frame budget. The result is far below 60fps.",[13,6654,6655,6656,6660,6661,6664],{},"Chrome shows a ",[17,6657,6659],{"href":6527,"rel":6658},[44],"trick",". You stack some precomputed copies with an exponentially higher blur, and you cross-fade between them with ",[90,6662,6663],{},"opacity",". Opacity is compositor-only and therefore cheap. You do not animate the blur, you simulate it.",[57,6666,6668],{"id":6667},"the-solution","The solution",[13,6670,6671],{},"In this case the solution was almost too simple. We removed the blur completely.",[13,6673,6674,6675,6677],{},"The old version blurred a sharp circle, and that was expensive. The glows are now radial gradients with multiple steps, and these gradients are soft by default. The result looks almost identical, but without a filter, without an extra layer, without a convolution. I also removed the ",[90,6676,6601],{}," in the header.",[13,6679,6680],{},"Other valid options exist, dependent on the case. Deliver a pre-blurred image as an asset, keep the radius small, or apply the blur to a smaller element.",[13,6682,6683],{},"The result is clear. The page now appears in less than half a second instead of more than five seconds. It is ten times faster, and the design looks the same.",[57,6685,6687],{"id":6686},"how-you-find-such-a-problem","How you find such a problem",[13,6689,6690,6691,6696],{},"In the Chrome DevTools, the ",[17,6692,6695],{"href":6693,"rel":6694},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fdevtools\u002Frendering\u002Fperformance",[44],"Rendering tab"," leads quickly to the cause:",[1688,6698,6699,6705,6715],{},[1691,6700,6701,6704],{},[5763,6702,6703],{},"Paint Flashing"," shows in green what the browser paints again. A header that blinks green all the time during scroll is a clear signal.",[1691,6706,6707,6710,6711,6714],{},[5763,6708,6709],{},"Layer Borders"," and the ",[5763,6712,6713],{},"Layers tab"," show which elements get their own compositing layer. Each blur appears there.",[1691,6716,4774,6717,6720],{},[5763,6718,6719],{},"Performance panel"," shows the long paint tasks and GPU tasks directly in the flame chart.",[57,6722,6724],{"id":6723},"the-role-of-older-browsers","The role of older browsers",[13,6726,6727],{},"My first thought was that all browsers support blur. Caniuse confirms this, but support says nothing about performance.",[13,6729,6730,6733,6734,6739],{},[5763,6731,6732],{},"GPU acceleration can be absent completely."," Firefox renders with WebRender, but on many Linux setups (old Intel GPUs, proprietary Nvidia drivers) it falls back to ",[17,6735,6738],{"href":6736,"rel":6737},"https:\u002F\u002Fwiki.archlinux.org\u002Ftitle\u002FFirefox\u002FTweaks",[44],"software rendering",". Then the blur convolution runs on the CPU, and a smooth effect becomes visible stutter.",[3968,6741,6742],{"color":4236,"icon":6153},[13,6743,6744],{},"A green Caniuse bar means that the browser knows the feature. It does not mean that the browser is fast without GPU acceleration.",[57,6746,6748],{"id":6747},"what-i-learned","What I learned",[13,6750,6751],{},"The most beautiful effect has no value when it makes the page slow. Blur is expensive to compute, it creates layers, it has side effects on stacks and positions, and it is fragile on mobile and without GPU acceleration. And always test on real, weak devices, not only on a fast developer machine.",{"title":88,"searchDepth":127,"depth":127,"links":6753},[6754,6755,6760,6761,6763,6764,6765,6766,6767],{"id":59,"depth":127,"text":6499},{"id":6518,"depth":127,"text":6519,"children":6756},[6757,6758,6759],{"id":6548,"depth":147,"text":6549},{"id":6555,"depth":147,"text":6556},{"id":6562,"depth":147,"text":6563},{"id":6578,"depth":127,"text":6579},{"id":6594,"depth":127,"text":6762},"filter is not backdrop-filter",{"id":6648,"depth":127,"text":6649},{"id":6667,"depth":127,"text":6668},{"id":6686,"depth":127,"text":6687},{"id":6723,"depth":127,"text":6724},{"id":6747,"depth":127,"text":6748},"2026-06-12","A deep dive into why CSS blur is one of the most expensive effects in the browser, and how a single filter made the landing page slow on mobile.",{},"\u002Fupdates\u002Fmobile-performance",{"title":6487,"description":6769},"updates\u002Fmobile-performance","Performance","FCPPOdSvcCXgkXyBppiWVwtghK7RcwP-2r_z3oYwoiU",{"id":6777,"title":6778,"body":6779,"date":6851,"description":6852,"extension":1734,"meta":6853,"navigation":522,"path":6854,"seo":6855,"stem":6856,"tag":4261,"__hash__":6857},"updates_en\u002Fupdates\u002Fbeta-tester.md","Beta Testers Wanted",{"type":7,"value":6780,"toc":6845},[6781,6788,6792,6795,6809,6812,6816,6819,6823,6826,6830,6840],[13,6782,6783,6784,36],{},"Knecht runs. What it needs now are real projects, because local tests do not produce real bugs. So we search for a handful of agencies and freelancers that use Knecht first in their daily work. If you want to join right away, sign up in the ",[17,6785,6787],{"href":6786},"#cta","form below this post",[57,6789,6791],{"id":6790},"what-you-get","What you get",[13,6793,6794],{},"The start should cost you nothing. So this is what you get from us:",[1688,6796,6797,6800,6803,6806],{},[1691,6798,6799],{},"You do not have to install Knecht yourself. We host your instance during the beta and take care of updates and operations.",[1691,6801,6802],{},"100 € of OpenCode credit, so that the AI agents can start right away.",[1691,6804,6805],{},"The first year after the release is free for you.",[1691,6807,6808],{},"Your logo with a link on the home page.",[13,6810,6811],{},"So you take no financial risk, and you start with a functional Knecht, configured for your projects. If you prefer to run Knecht on your own server, you can still do that.",[57,6813,6815],{"id":6814},"what-we-need","What we need",[13,6817,6818],{},"In return, we want one person who really uses Knecht and tells us regularly where it blocks them, what is missing, and what works well. We do not ask for more. This feedback from real projects is worth more to us than any local test.",[57,6820,6822],{"id":6821},"who-is-a-good-fit","Who is a good fit",[13,6824,6825],{},"Knecht helps most where many projects need maintenance. You are a good fit if you work with DDEV and maintain enough projects that security updates and bug fixes are routine work for you. If your projects do not run on DDEV yet, but you can convert them with little effort, that also works.",[57,6827,6829],{"id":6828},"take-part","Take part",[13,6831,6832,6833,6835,6836,36],{},"If this sounds like you, sign up in the ",[17,6834,6787],{"href":6786}," and select \"I want to test actively and give feedback\". We will then contact you. If you have questions first, send a mail to ",[17,6837,6839],{"href":6838},"mailto:hallo@knecht.works","hallo@knecht.works",[13,6841,6842,6843,36],{},"And if testing is not your thing, but you want to follow the progress, select \"Get updates only\" in the ",[17,6844,1056],{"href":6786},{"title":88,"searchDepth":127,"depth":127,"links":6846},[6847,6848,6849,6850],{"id":6790,"depth":127,"text":6791},{"id":6814,"depth":127,"text":6815},{"id":6821,"depth":127,"text":6822},{"id":6828,"depth":127,"text":6829},"2026-06-08","We search for a handful of agencies and freelancers that run Knecht on real projects. We cover the hosting, the credit, and the first year.",{},"\u002Fupdates\u002Fbeta-tester",{"title":6778,"description":6852},"updates\u002Fbeta-tester","6tV42Jup9hSn8mg5f8V5WDisDSYAmmKFyR8S_QDTY-4",{"id":6859,"title":6860,"body":6861,"date":6928,"description":6929,"extension":1734,"meta":6930,"navigation":522,"path":6931,"seo":6932,"stem":6933,"tag":4261,"__hash__":6934},"updates_en\u002Fupdates\u002Flandingpage.md","The Landing Page Is Online",{"type":7,"value":6862,"toc":6921},[6863,6866,6872,6876,6879,6890,6894,6897,6901,6904,6906,6909,6912],[13,6864,6865],{},"The landing page is online. We made this decision on purpose, before the prototype even exists.",[3968,6867,6869],{"color":4236,"icon":6868},"i-lucide-mouse",[13,6870,6871],{},"Click the Knecht.",[57,6873,6875],{"id":6874},"why-this-page","Why this page",[13,6877,6878],{},"It is a risk to build a full product first and then hope that somebody needs it. Thus the landing page comes first. The page does three things:",[1688,6880,6881,6884,6887],{},[1691,6882,6883],{},"It explains the idea in a few sentences.",[1691,6885,6886],{},"It collects the first beta testers.",[1691,6888,6889],{},"It makes the path public, so that feedback comes early and not only at launch.",[62,6891,6893],{"id":6892},"language","Language",[13,6895,6896],{},"Many startups aim at the international market from the start. We think that this starts with the language. We will focus on the DACH region first. Thus English as a second language must wait a little longer.",[57,6898,6900],{"id":6899},"what-the-page-is-not-yet","What the page is not (yet)",[13,6902,6903],{},"We want to prevent false expectations. This page is not a product. It has no login, no demo, and nothing that you can try. You see the idea and a first look at the planned interface.",[57,6905,6451],{"id":6450},[13,6907,6908],{},"The prototype comes next, with projects, workflows, and the AI agent.",[13,6910,6911],{},"The goal for the next weeks is clear. We want to find the first 3 to 5 agencies as beta testers. They test Knecht on real projects and give honest feedback about what works.",[3968,6913,6914],{"color":3970,"icon":3971},[13,6915,6916,6917,6920],{},"Sign up as a ",[17,6918,6919],{"href":6786},"beta tester"," if you want to join early.",{"title":88,"searchDepth":127,"depth":127,"links":6922},[6923,6926,6927],{"id":6874,"depth":127,"text":6875,"children":6924},[6925],{"id":6892,"depth":147,"text":6893},{"id":6899,"depth":127,"text":6900},{"id":6450,"depth":127,"text":6451},"2026-06-07","Why the landing page comes before the product, and what comes next.",{},"\u002Fupdates\u002Flandingpage",{"title":6860,"description":6929},"updates\u002Flandingpage","mHAfAuuFvUBXJQn0P6tCeqkTkzeP_GmbaTIwOH4SdBU",{"id":6936,"title":6937,"body":6938,"date":7044,"description":7045,"extension":1734,"meta":7046,"navigation":522,"path":6216,"seo":7047,"stem":7048,"tag":4261,"__hash__":7049},"updates_en\u002Fupdates\u002Fwas-macht-knecht.md","What Knecht Does",{"type":7,"value":6939,"toc":7031},[6940,6943,6947,6950,6953,6957,6963,6967,6970,6980,6983,6986,6989,6992,6995,6998,7002,7005],[13,6941,6942],{},"A bug report always starts with an attempt to reproduce the error. After the fix, you must make sure that the error is really gone. This sounds simple, but often it is not.",[57,6944,6946],{"id":6945},"motivation","Motivation",[13,6948,6949],{},"Agencies often maintain many projects. These projects often have different configurations, or they run only on the computer of the one employee who was there from the start. Still, all projects need regular security updates and bug fixes.",[13,6951,6952],{},"AI agents can only really help here when the projects boot completely.",[62,6954,6956],{"id":6955},"security-updates","Security Updates",[13,6958,6959,6960,6962],{},"To install a security update in a CMS, the database must run in almost all cases. After an update, database migrations can follow. Thus Dependabot and other bots cannot just do a ",[90,6961,5813],{}," and deploy the result.",[62,6964,6966],{"id":6965},"bug-fixes","Bug fixes",[13,6968,6969],{},"Unfortunately, a bug fix is not always this simple.",[6971,6972,6974],"prompt",{"description":6973},"Pls fix!",[13,6975,6976],{},[17,6977,6978],{"href":6978,"rel":6979},"https:\u002F\u002Fyoutu.be\u002FdQw4w9WgXcQ?t=0",[44],[13,6981,6982],{},"For a bug fix, an employee must first be available. In the best case, this employee already has the project on the local machine and can solve the error at the root. Often, no such employee is available.",[57,6984,3133],{"id":6985},"ddev",[13,6987,6988],{},"DDEV is a development environment that is based on Docker. With DDEV, you can run a full project. You do not need to install developer tools directly on your device.",[13,6990,6991],{},"Each project boots in a reproducible and isolated way. The web server and the database start with a clean configuration, always in the same way. This reliability is the base for everything else. When a project always boots in the same way at the push of a button, a machine can also boot it.",[57,6993,6217],{"id":6994},"what-knecht-does",[13,6996,6997],{},"We build Knecht as a dashboard that you host on your own server. Thus you keep your data. The dashboard shows preview links and screenshots (made with Penthouse). With them, you can verify a bug fix directly.",[62,6999,7001],{"id":7000},"how-it-works","How it works",[13,7003,7004],{},"Knecht has projects, workflows, and triggers.",[3552,7006,7007,7011,7014,7018,7021,7024,7028],{"level":6265},[62,7008,7010],{"id":7009},"projects","Projects",[13,7012,7013],{},"A project is always a GitHub repository in which DDEV is already configured. In the project, you can also set environment variables and the database.",[62,7015,7017],{"id":7016},"workflows","Workflows",[13,7019,7020],{},"A workflow is a predefined process that works with projects. One example is this sequence: boot the project → update the Composer packages → create a PR with the changes. Knecht does these steps in a deterministic order.",[13,7022,7023],{},"But bug fixes are rarely the same repeatable steps. For them, Knecht uses AI agents. Through Opencode, the agents can reproduce and repair a bug in the booted project.",[62,7025,7027],{"id":7026},"triggers","Triggers",[13,7029,7030],{},"A trigger is the start point of a workflow. Through webhooks, for example from Jira or GitHub, Knecht can start predefined workflows automatically. Knecht then delivers the results as a pull request.",{"title":88,"searchDepth":127,"depth":127,"links":7032},[7033,7037,7038],{"id":6945,"depth":127,"text":6946,"children":7034},[7035,7036],{"id":6955,"depth":147,"text":6956},{"id":6965,"depth":147,"text":6966},{"id":6985,"depth":127,"text":3133},{"id":6994,"depth":127,"text":6217,"children":7039},[7040,7041,7042,7043],{"id":7000,"depth":147,"text":7001},{"id":7009,"depth":147,"text":7010},{"id":7016,"depth":147,"text":7017},{"id":7026,"depth":147,"text":7027},"2026-05-15","About the idea, the motivation, and how Knecht helps.",{},{"title":6937,"description":7045},"updates\u002Fwas-macht-knecht","cLtvIAvSysiOx-uvNErYy0EcL2WEuvU9UnRdPM9O7mc",1790265009688]