Custom Workflow In TeamForge: A Guide To IAF Event Handlers

Imagine you could add arbitrary workflow rules to TeamForge …

TeamForge is definitely one of the most open and expandable ALM platform out there. A very powerful but not widely known mechanism to customize its behavior are so called custom event handlers which allow you to intercept arbitrary TeamForge events, block them if necessary or trigger follow up actions. Imagine you want to block deletions of projects for all users, add a comment to a tracker item whenever an association has been modified or come up with your own tracker workflow engine: TeamForge custom event handlers let you handle all that. TeamForge custom event handlers are part of the Integrated Application Framework (IAF) introduced in TeamForge 5.4, marked beta in TeamForge 6.1 and probably ready for prime time in TeamForge 6.1.1.

In this blog post you will learn

  • How custom event handlers interact with TeamForge
  • How to write your own custom event handlers (including best practices)
  • How to benefit from custom event handlers without having to write a single line of Java
  • How to avoid common pitfalls in event handler design
  • How to trouble shoot your event handlers
  • How you can help us improving our existing documentation on event handlers

Writing custom event handlers requires at least some basic knowledge in the Java programming language or (if you use the example shipped with this post) a script language that is installed on the TeamForge server (shell, Python, Perl, …). The following sections of this blog post assume that you are familiar with basic programming techniques. If I have not scared you away, read on …

… you actually can

TeamForge’s custom event handling framework implements an extended flavor of the observer pattern. It allows third party event handlers to register for TeamForge specifi c application events and will notify them whenever such an event occurs. TeamForge application events are triggered whenever a property of a TeamForge object (e.g. tracker item, discussion item, wiki page) has been changed or is going to be changed if no event handler objects (i. e. blocks the event).

The framework di fferentiates two diff erent types of events: If a handler registers for asynchronous events, it is informed that a change had just happened. The handler can decide to trigger further changes by calling TeamForge web services, but it cannot block the change because it has already happened. If a handler has registered for synchronous events it gets informed whenever a change has been anticipated by a user. It can examine the properties that should be changed and decide whether to accept the change or block it. A synchronous event handler cannot trigger further changes on the currently processed object since other handler in the event handler chain must also have the chance to block the anticipated change.

Technically, all event handlers have to be part of a Java archive (jar-file) with a TeamForge specifi c deployment descriptor that describes which events should be intercepted. This jar file then has to be uploaded to the TeamForge application server (no restart necessary but the event handling cache has to be refreshed).

How do I write my own custom event handler?

I am a great believer in the power of example code when learning new things. This is why I will try to show how to come up with your own custom event handlers based on two examples. The first example is an event handler that adds a comment to a tracker item whenever an association is added or deleted to this tracker item. This example shows how to intercept a specific event, trigger a follow up action by calling TeamForge’s web services and adds a comment based on the formatting template which is specified as part of a property file.

The code for example one can be found here (for those not familiar with jar files: You can treat jar files like zip files, just extract them with your favorite zip program and have a look at the files that are part of it).

The second example is more generic (and more complicated to understand since it is making heavy use of Java reflection) and shows how to intercept arbitrary TeamForge events, examine the event’s properties, map them to system environment variables and call a script in the file system which name corresponds to the intercepted event. You can use this event handler to customize TeamForge’s behavior without any knowledge of the Java programming language as long as you can write scripts in a language that can deal with environment variables, write to standard out/error (to influence what will be displayed in TeamForge’s UI as result of the handler’s execution) and influence the return code (to decide whether to block the event or not).

The code for example two can be found here (for those not familiar with jar files: You can treat jar files like zip files, just extract them with your favorite zip program and have a look at the files that are part of it).

The example event handlers are something I came up with in my free time, so it is provided as-is (and not shipped as official part of any TeamForge release). As for all event handlers, use them at your own risk since CollabNet cannot guarantee any SLAs on third party code. With that in mind, let’s inspect the content of the jar files.

What is inside a custom event handling jar file?

If you extract a custom event handling jar file, you should see a structure like this:

  • com/vasoftware/sf/plugin : This directory contains the class file of your event handler (has to be in that package)
  • META-INF/event.xml: This file contains the events and operations, your handler class should intercept

Furthermore, there might be additional directories containing Java class files. If you like to include Java libraries, you have to unpack their jar files and add their class files (including directory structure) in the event handler jar.

It is also common to have additional files in the META-INF directory, like property files to control the behavior of the event handler. Example one (our association converter) e.g. contains a file called “” that is used to control the formatting of the comment that gets added whenever an association has been modified. The initialize-method of the handler class (AsynchronousRelationshipEventListener) shows how property files can be parsed within a custom event handler.

If you want to change the behavior of custom event handlers at runtime (without redeploying the jar file), you might want to look at TeamForge’s integration data API. For the purpose of this example, we will stick with property files and now examine the content of the event.xml file.

What to put inside the event.xml file?

If you open the event.xml file of example one (association converter), you will find some lines like

 <event api="5.0" mode="asynchronous">

This tells the event handling framework that the class AsynchronousRelationshipEventListener is responsible to intercept events of type “relationship” (aka associations) for every possible operation. The handler will be called after the event actually happened (asynchronous mode) and the passed data structures will be compatible with the events format introduced in TF 5.0 (IOW, the event handler extends the EventHandler50 class). If you only want to intercept certain operations, you can specify those instead of the wildcard character (*). Supported operations are usually create, update, move and delete but every event may define its own operations.

Let’s proceed with the event.xml file of our second example (hook script executor):

 <event api="5.4" mode="asynchronous">

 <event api="5.4" mode="synchronous">

These lines tell TeamForge to register two event handlers, one asynchronous (AsynchronousHookScriptEventListener) and one synchronous (SynchronousHookScriptEventListener) for arbitrary events (wildcard *). It also tells TeamForge that the handler classes will extend the EventHandler54 interface and are able to handle events introduced in TeamForge 5.4.

That’s pretty much what you can define in event.xml. It is possible to register multiple handlers for different events but you could also use one handler to intercept both synchronous and asynchronous events. One aspect I have not covered so far is the user element. By default, the user triggering the event is also the user executing the event handler. If you like to run your event handler with a different user account, you can specify this in the user element like

<event api="5.4" mode="synchronous">

Be careful with that option since running code on behalf of a different user opens the door for all kind of exploits if you do not check the user’s input properly. I personally never used that option because it will not allow you to access the original user’s session id any more (whereas you can always create a new session with a super user account (credentials saved in a property file) if you have to.

You probably noticed that I have not answered one of the most important questions yet: Which Java class does my handler have to implement and where do I get the interface classes from? The next section reveals this secret to you.

Which Java class do I have to extend for my handler classes?

All event handlers (synchronous and asynchronous) have to extend (the version number and exact package name may vary from TeamForge version to TeamForge version but EventHandler50 will work for both TeamForge 5,3, 5.4 and 6.1). This is how example one (association converter) does it:

package com.vasoftware.sf.plugin;
public class AsynchronousRelationshipEventListener extends EventHandler50 {

EventHandler50 can be found in the sfevents.jar file deployed somewhere in your TeamForge server (just do a find / -name “sfevents.jar” to search for it). You can download the 5.4 version (also compatible with later versions of TeamForge) from here.

EventHandler50 has one abstract method you have to override:

public void processEvent() throws Exception {

As you probably have noticed, this method does not take any parameters, so how do we access the relevant information? There are some interesting methods of the base class (EventHandler50) you may call:

  • getSessionKey: Returns a session id of the user who is going to (synchronous handler) / has triggered (asynchronous handler) the event we just intercepted. If you used the user element in event.xml, it will contain a session id for the user you specified there.
  • getEventContext: Returns a data structure containing the event type, operation, project, comment and user name
  • getOriginalData: In case of a synchronous event handler, this will return a representation of the object the event is going to change. In case of an asynchronous event handler, this will return the representation of the object before it was changed by the event. The data structure used to represent the object is the same that would have been used in CollabNet’s SOAP API.
  • getUpdatedData: In case of a synchronous event handler, this will return a representation of the object how it will look after the event has happened (you can still block it). In case of an asynchronous event handler, this will return the representation of the object after it was changed by the event.

The last two methods may need some additional explanation: Let’s assume a user wants to change the priority of a tracker item from 3 to 4. If you have registered a synchronous event handler, this one will be triggered before the change will actually be performed. getOriginalData will return an ArtifactSoapDO object of the tracker item with the priority field set to 3. getUpdatedData will contain an ArtifactSoapDO object of the tracker item with the priority field set to 4. If you block the event (by throwing an exception), the change will not happen and the user will be presented an error message (see next section how to influence this error message). If you do not block the event (by just returning from the processEvent method), all registered asynchronous handlers will be called. getOriginalData and getUpdatedData will contain exactly the same objects as in the synchronous case. However, the semantic is different: They are no longer representing the current and anticipated state but the previous and current state of the object in question.

The following code snippet (taken from example two) shows how to retrieve all information available to an event handler:

String type = getEventContext().getType();
String operation = getEventContext().getOperation();
String projectId = getEventContext().getProjectId();
String comment = getEventContext().getComment();
String userName = getEventContext().getUsername();
String originalDataClassName = getOriginalData().getClass().toString();
String updatedDataClassName = getUpdatedData().getClass().toString();
Object originalData = getOriginalData();
Object updatedData = getUpdatedData();

Now that we extracted all data available to the event handler, how do we interact with the TeamForge UI? Let’s jump to the next section to find out.

How can I interact with the TeamForge UI / Block events?

Only synchronous event handler can directly communicate with TeamForge’s UI. The reason is simple: If the event has already happened (as it is the case for asynchronous handlers), the user who triggered the event may already have been logged out. You basically have three independent actions to interact with TeamForge’s UI:

  • Add a success message to the UI that gets displayed as the result of the action just triggered by the user: This can be done by calling the addSuccessMessage method of the EventHandler base class (see of example two for details)
  • Add an error message to the UI that gets displayed as the result of the action just triggered by the user: This can be done by calling the addErrorMessage method of the EventHandler base class
  • Block the event you intercepted. This can be done by throwing an exception in the processEvent method. The payload of your exception will be displayed in the UI.

All three ways of UI feedback can be used in combination: It is possible to display an error message although you did not block the event and it is also possible to show many error and success messages together.

What happens if the event in question was not triggered by a user logged into TeamForge’s Web UI but by a client using TeamForge’s web services? In this case, error and success messages will not reach the SOAP client. However, the payload of the exception object thrown when the event was blocked will be delivered as part of the SOAP fault element.

While synchronous event handlers enable you to block events and/or to provide additional feedback to the currently logged in user, they should not be used to trigger follow up actions (like changing TeamForge artifacts or interacting with external systems). Remember that these handlers are running in the main TeamForge event loop and nothing else will happen until you return from the processEvent method, so return as fast as you can. The next section will cover how to trigger follow up actions in asynchronous event handlers.

How can I trigger follow up actions?

I cannot emphasize this strongly enough: Only communicate with TeamForge, external systems, processes or system resources in asynchronous event handlers (not in synchronous event handlers) in order not to accidentally lock the main TeamForge event queue down (and essentially render the system unusable).

Interacting with external systems, processes or resources is done as you would do in any other Java program. You may use external libraries in your event handler by placing their .class files into your event jar file. The only tricky part is if TeamForge is using a different version of this library (which will take precedence). In this case, you would have to recompile your library with a different package namespace.

Interacting with TeamForge is done as you would do it if you had to write a Java program that should interact with TeamForge using its web services API. The only difference is that you will connect to localhost (since your handler is running locally) and that you already have a valid session id. You will not have to include the SOAP SDK classes in your event jar file since they are already in TeamForge’s class path. The code snippets extracted from example one (association converter) shows how to do it:

ITrackerAppSoap trackerClient = (ITrackerAppSoap) ClientSoapStubFactory.getSoapStub(
ITrackerAppSoap.class, "http://localhost:8080");
ArtifactSoapDO artifact = trackerClient.getArtifactData(getSessionKey(), originId);
trackerClient.setArtifactData(getSessionKey(), artifact, finalComment, null, null, null);

Using the session key provided by the event handler is actually only going to work if the SOAP call you are using is not throwing an exception: The session id passed into your handler is associated with an already running transaction that will be aborted if an exception is thrown as part of this session. Part of rolling back the transaction is rolling back the JVM’s call stack which contains your event handling code, so you will not be able to catch the web services exception. If you like to to handle web service exceptions, you have to create your own session id by logging into TeamForge again by calling ICollabNetSoap.login with some credentials stored as part of your handler (you may store them in a property file of the META-INF directory).

At this point, I like to spend some words about event handler life cycle: For every single call to the processEvent method, a new object of your class will be instantiated. A best practice to avoid costly reinitialization every time (remember that the TeamForge event loop thread is blocked while you are doing this) is to delegate all synchronization work to a method you always call in your constructor which checks a static variable whether the initialization has already been done and if not, just returns without any further action (code snippet from example one):

private static boolean initialized = false;
public  AsynchronousRelationshipEventListener() {

 private synchronized void initialize() {
 if (initialized ) {
 initialized = true;
// proceed with initialization

Logging in into TeamForge / initializing network connections / file resources are costly operations that should be handled in such a method instead of doing it all over again.

While it is true that asynchronous handlers may consume considerably more time than synchronous ones, there is only one thread for those handlers so events may queue up if you do expensive operations. A best practice is to capture the event in your asynchronous event handler, write all necessary information to the local file system (comparable to a mail spoiling directory) and return. At the same time, you can have a separate application reading from the spooling directory. Doing so, you never get into a situation where you miss TF events/things queue up just because you run into a blocking operation.

Now that you know how to intercept events, interact with TeamForge and trigger follow up actions, the only remaining question is which events and operations are available and how to inspect them properly. This is what the next sections are talking about.

Which events and operations are available?

We already briefly covered the available operations (create, update, delete and move) but as mentioned before, every event is free to introduce its own operations. The number of events is growing with every version of TeamForge. The easiest way to figure out which events exist (and which you have to intercept) is to write an asynchronous event handler that intercepts all possible events and operations (use * wildcard in event.xml) and write them to a log file. Then, install the handler, trigger the event you are interested in and have a look at the log file. Fortunately, the event handler from example two (hook script executor) already does something like this:

 String type = getEventContext().getType();
 String operation = getEventContext().getOperation();
 String hookScriptName = hookDirectory + type + "_" + operation;"Going to call " + hookScriptName + " if it does exist.");

From the output you can conclude that there are event types “user”, “documentfolder”, “project”, “forum”, “tracker”, “role”, “relationship”, “planningfolder”, “artifact”, “task”, “repository” and many many more (my kudos to anybody who compiles a more complete list).

How do I inspect the event’s properties?

If you know which event types you are expecting, you can just call the getter functions of the original and updated data object after you casted it to the exact class you expected. This is exactly what happens in our first example (association converter):

RelationshipSoapDO relationShip = (RelationshipSoapDO) getUpdatedData();
String originId = relationShip.getOriginKey();
String targetId = relationShip.getTargetKey();
String description = relationShip.getDescription();

We know that the event in question is a relationship event, so we cast it to RelationshipSoapDO and have a look at its properties by calling the getOrigin, getTargetKey and getDescription methods. If you know the class name of the event type you are expecting, you can also use our Javadocs to find out about the provided information.

The hook script example code shows how to inspect properties of unknown event types in a generic way by introspecting the event’s class get – methods, calling them and store the result type in an environment variable with the name of the property:

Class originalDataClass = getOriginalData().getClass();
 Method[] methods = originalDataClass.getMethods();
 for (Method m : methods) {
 // we are only interested in getter methods
 if (!m.getName().startsWith("get")) {
 // we are only interested in methods that do not take any parameters
 if (m.getParameterTypes().length != 0) {
 // should we only invoke methods with a simple return type?
 Object result = m.invoke(originalData);
 environment.put("tf_original_"+ m.getName().substring(3), result.toString());

You may ask why we store the properties in system environment variables though. This brings me to the next section that explains how example two (hook script executor) can be used to benefit from the custom event handling framework without having to write a single line of Java code.

How can I make use of the custom event handlers without a single line of Java?

The “TeamForge Hooks” sample handler (example two) basically exposes all TF events to non Java programmers. Whenever a TF event arrives, it looks whether there is a script in the TF file system with the name/operation of the event and will then call this script with all information from the event contained within environmental variables.

There are two types of hook available; Synchronous and Asynchronous.

Synchronous Hooks

These hooks are called BEFORE an event is completed. If the hook script exits with a non-zero error status then the event is not allowed. Anything that got printed to STDERR gets printed to the screen as an error message to the user. Synchronous hook scripts need to perform well. A badly behaved synchronous script can lock whole TF down if it does not quit, to that end please consider deploying a wrapper script that terminates your main script after a certain timeout.

Asynchronous Hooks

These hooks are called AFTER an event has taken place, the application does not wait at all for these to complete and there is no opportunity to provide any feedback into the user interface.

What Hooks/Events are available?

To see what hooks are available all you need to do is look into the server.log file and interact with TeamForge. Assuming that the log level is set to INFO you’ll see lines like the following:

Going to call /opt/collabnet/teamforge/hooks/asynchronous/user_update if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/project_update if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/documentfolder_create if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/document_create if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/document_delete if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/documentfolder_delete if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/tracker_create if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/tracker_update if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/tracker_delete if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/role_create if it does exist.
Going to call /opt/collabnet/teamforge/hooks/synchronous/forum_create if it does exist.
What Information gets passed to a hook?

The hooks are initialized with environment variables corresponding to all the available information for that event. All the variables carry a tf_ prefix.

The number of environmental variables passed depends from event to event. The easiest way to find out would be to create a script for the event you are interested in that looks like

set > /tmp/hook_output

In case of the repository_create script, the output would be

tf_updated_LastModifiedDate=Mon Dec 07 08:15:40 PST 2009
tf_original_LastModifiedDate=Mon Dec 07 08:15:40 PST 2009
tf_updated_CreatedDate=Mon Dec 07 08:15:40 PST 2009
tf_original_CreatedDate=Mon Dec 07 08:15:40 PST 2009
What permissions do I need on the hook script?

Hooks need to be owned by sf-admin for security. And need to have the executable bit set.

Can you give us examples for hook scripts?

To configure a site to prevent projects being deleted, we could create the following file: /opt/collabnet/teamforge/hooks/synchronous/project_delete

echo Sorry, projects cannot be deleted on this site 1>&2
exit 1

To prevent any update of a document stored in TeamForge without providing a version comment, we would create the following file: /opt/collabnet/teamforge/hooks/synchronous/document_update

if [ $tf_comment="null" ]; then
echo "Sorry, you have to specify a comment during document update" 1>&2
exit 1
exit 0

If you like to asynchronously (using nohup and Jenkins CLI) trigger a Jenkins job called TestJob on a Jenkins instance running on, whenever a tracker artifact in tracker tracker1011 gets transitioned to status BUILD APPROVED, we would create the following file /opt/collabnet/teamforge/hooks/asynchronous/artifact_update

# tracker1011 is the tracker id of the tracker you are interested in, change to your specific tracker 
if [ "$tf_original_FolderId" = "tracker1011" ] then
        if [ "$tf_original_Status" != "BUILD APPROVED" ] && [ "$tf_updated_Status" = "BUILD APPROVED" ]
                echo "Going to trigger Jenkins job"
                # Modify Jenkins URL, location of jenkins cli war and job (TestJob) appropriately
                nohup java -jar /opt/collabnet/teamforge/hooks/asynchronous/jenkins-cli.jar -s build TestJob 1>&2 2>/dev/null &
                echo "Correct tracker but no status transition to trigger a Jenkins build"

        echo "Different tracker, do not inspect status transitions "

If you want to create an initial directory structure in an SVN repository once it is created, create the following file: /opt/collabnet/teamforge/hooks/asynchronous/repository_create

/usr/bin/svn mkdir
-m "Inital Structure"
--username admin --password mypassword --non-interactive --no-auth-cache
exit 0

Please note that this concrete event handler is not officially supported by CollabNet and have to be used at your own risk. Its main purpose is to give you an idea how custom event handlers can be written. Feel free to adjust it to your own needs.

There are some caveats of this sample handler you absolutely have to know:

  • The calling and waiting for synchronous hooks currently doesn’t have a timeout. As long as your synchronous hook is running, the whole TF site will be blocked for all users accessing the site.
  • Some events trigger other events. For example creating a project actually calls the create project hook, wiki page hooks etc etc. Again, badly written or slow hooks could cripple a site.
  • Write your diagnostics messages in a file and not on stdout/stderr since TF does not read from stdout/stderr before the script completed which in case of synchronous hooks could leave to a situation where the script blocks because the pipe’s buffer between the script process and the TF process is completely filled.

Further things you have consider before writing hook scripts or custom event handlers in Java are summarized in the following section.

Are there any pitfalls with custom event handlers I should know?

The official documentation already mentions some points you have to be careful about when writing custom event handlers. Most importantly, be aware of the fact that while your synchronous event handler is running, no other events can be processed, so if your code contains any operation that might block, you could lock the whole TeamForge site down.

The other thing you have to be aware of is that your follow up actions may trigger your handler to be called again. You have to protect your handler from an infinite update loop in that case. A best practice is to add a check to your event handler whether the user initiating the event is the user you are using to perform follow up actions.

You learned that throwing an exception in a synchronous event handler will block the intercepted event and roll back the transaction associated with the change. Rolling back transactions also means that the data the user entered will not be saved. If this happened accidentally due to a wrongly programmed event handler, this would be really frustrating to your users, so please make sure that you only throw exceptions in your handler code when you really want to enforce the rollback. This statement sounds trivial at the first glance but it is quite easy to miss an exception you did not expect (like a null pointer exception, parsing exception, time out exception, any other malfunction in your own code). A best practice is to introduce a generic catch block in your handler and only rethrow the exception if it was an “intended” exception (see SynchronousHookScriptEventListener):

} catch (Exception e) {
 if (!intendedException) {
 log.error("Exception occured: " + e.getMessage(), e);
 } else {
 throw e;

My custom event handler is not accepted/behaves weird, what is wrong?

Writing event handlers from scratch is really hard because the event handler parser is really picky on the exact format of your jar file. A best practice is to base your work on an already existing event handler and then adapt it to your own needs by doing incremental changes while checking whether it still works.

If you get a message in server.log (or events.log) that your event handler could not be parsed, these are some points you might check:

  • Did you change the package of your class containing handler code (this is not supported)
  • Did you move event.xml to another directory (not supported either)?
  • Did you compile your java code with a Java version that is not supported by the JVM running on the TeamForge server?
  • Did you use a very long file name for your jar file (only 31 characters supported)?
  • Did you include libraries in your jar file that have already been part of the default TeamForge class path (in this case, your libraries will not be picked up)?
  • Did you try to reference internal TF classes from packages other than the SOAP namespace (in this case, the TF security classloader will reject your jar file)?
  • Did you change the DTD location referenced in events.xml (this will not work)?
  • Did you try to trigger a follow up action in a synchronous event handler (this will most likely result in a time out exception since your code is already running in a transaction that locks the resources you are trying to obtain a lock for)?
  • Did you trigger a TeamForge SOAP call with the session id passed to your handler which threw an exception (see section “How can I trigger follow up actions for an explanation why this does not work”)?

I am sure there’s lots of other gotchas I missed, so please read the final section to find out how you can help improve our documentation.

Why we absolutely need your feedback?

While some documentation for custom event handlers is already available on, we realized in our community forums, that there is some need for more advanced documentation. This blog post tries to give you all information needed to write your own event handlers. If you miss certain information, just write me a comment and I will try to cover your questions in a follow up post. Your comments will help CollabNet to figure out what we have to add to our official documentation once IAF is marked as stable.

Johannes Nicolai

Johannes Nicolai is CollabNet’s Development Manager leading all Git and Gerrit related development efforts. Furthermore, he is responsible for CollabNet Connect /synch, CollabNet’s platform to integrate TeamForge with third party ALM platforms. Johannes holds a Master of Science in IT Systems Engineering from Hasso Plattner Institut Potsdam and is a Certified Scrum Master. Before joining CollabNet five years ago, he was doing consulting on user centric design, developing cryptographic software and architecting SAP integrations. He is an Open Source enthusiast and contributes to many projects (check out for details).

Tagged with: , , , , , ,
Posted in TeamForge
One comment on “Custom Workflow In TeamForge: A Guide To IAF Event Handlers
  1. Mohamed says:

    Thank you very much for this easy explanation. I need to ask a question about can I put custom events on clone operation not default operations like (create update move, delete ) and if it is yes can you tell me how

Leave a Reply

Your email address will not be published. Required fields are marked *